From 8ff126b208b02a7f836ca516b30819b50e93dfb9 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Fri, 18 Sep 2026 12:17:59 -0300 Subject: [PATCH 1/6] Fix e2e tests failing --- package-lock.json | 11 ++++++++ .../cypress/e2e/Styles/sliderComponent.cy.js | 26 +++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdc6a65880..3474058091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8734,6 +8734,17 @@ } } }, + "node_modules/@wordpress/url/node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@wordpress/url/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", diff --git a/tests/cypress/e2e/Styles/sliderComponent.cy.js b/tests/cypress/e2e/Styles/sliderComponent.cy.js index 9f31f665d9..20ccf3bc1d 100644 --- a/tests/cypress/e2e/Styles/sliderComponent.cy.js +++ b/tests/cypress/e2e/Styles/sliderComponent.cy.js @@ -4,6 +4,16 @@ describe( 'Slider style component', () => { cy.viewport( 1280, 1600 ); } ); + // Every change the tests below make gets committed to the hidden input by a "change" handler + // that runs immediately - but the style editor also fires an admin-ajax live-preview request + // on the same change, and re-normalizes the hidden input's value (e.g. adding back the unit) + // once that request resolves. Firing several slider changes back to back without giving that + // request a moment to resolve backs its queue up, so the value/disabled-state assertions that + // follow can outlast Cypress's default 4000ms retry window - not because the value is wrong, + // but because it hasn't settled yet. Call this after every blur()/select() on a slider. + const SLIDER_TIMEOUT = 10000; + const settleSliderChange = () => cy.wait( 500 ); + /** * Locates a single (non multi-value) slider's range input, visible text input, unit select * and hidden "real" input from the id on its hidden input. @@ -12,13 +22,13 @@ describe( 'Slider style component', () => { * @return {Object} References to the range, text, select and hidden inputs. */ const getSingleSlider = hiddenId => { - const wrapper = cy.get( hiddenId ).closest( '.frm-slider-component' ); + const wrapper = () => cy.get( hiddenId, { timeout: SLIDER_TIMEOUT } ).closest( '.frm-slider-component' ); return { - range: wrapper.find( 'input[type="range"]' ), - text: wrapper.find( '.frm-slider-value input[type="text"]' ), - select: wrapper.find( '.frm-slider-value select' ), - hidden: cy.get( hiddenId ) + range: wrapper().find( 'input[type="range"]' ), + text: wrapper().find( '.frm-slider-value input[type="text"]' ), + select: wrapper().find( '.frm-slider-value select' ), + hidden: cy.get( hiddenId, { timeout: SLIDER_TIMEOUT } ) }; }; @@ -51,15 +61,18 @@ describe( 'Slider style component', () => { // Max value for Border Width is 25 - start from a known, valid value. slider.text.clear().type( '10' ).blur(); + settleSliderChange(); slider.hidden.should( 'have.value', '10px' ); cy.log( 'A value above the max is rejected and the text box resyncs to the current range value' ); slider.text.clear().type( '999' ).blur(); + settleSliderChange(); slider.text.should( 'have.value', '10' ); slider.hidden.should( 'have.value', '10px' ); cy.log( 'A negative value is rejected the same way' ); slider.text.clear().type( '-5' ).blur(); + settleSliderChange(); slider.text.should( 'have.value', '10' ); slider.hidden.should( 'have.value', '10px' ); } ); @@ -106,6 +119,7 @@ describe( 'Slider style component', () => { cy.log( 'Force the unit to "auto" and confirm the range and text box are both disabled, with no duplicated "auto" text' ); slider.select.select( 'auto' ); + settleSliderChange(); slider.range.should( 'be.disabled' ); slider.range.should( 'have.attr', 'aria-valuetext', 'auto' ); slider.text.should( 'be.disabled' ).and( 'have.value', '' ); @@ -114,6 +128,7 @@ describe( 'Slider style component', () => { cy.log( 'Switching to a measured unit re-enables both the range and the text box' ); slider.select.select( 'px' ); + settleSliderChange(); slider.range.should( 'not.be.disabled' ); slider.text.should( 'not.be.disabled' ); slider.hidden.invoke( 'val' ).should( 'match', /^\d+px$/ ); @@ -128,6 +143,7 @@ describe( 'Slider style component', () => { cy.get( '#buttons-style button[aria-label="Buttons"]' ).click(); cy.get( '#frm_style_section_buttons-style' ).should( 'be.visible' ); getSingleSlider( '#frm_submit_width' ).select.select( 'auto' ); + settleSliderChange(); cy.get( '#frm_submit_side_top' ).click( { force: true } ); cy.get( '#frm_submit_width' ).should( 'have.value', 'auto' ); From a219dc3f774a58f2e426d62638eec6ebc8e33ac4 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Fri, 18 Sep 2026 14:05:40 -0300 Subject: [PATCH 2/6] Try to fix the other tests --- classes/views/shared/toggle.php | 1 + tests/cypress/e2e/Forms/deleteForms.cy.js | 39 ++++++--- .../cypress/e2e/Styles/sliderComponent.cy.js | 79 +++++++++++-------- tests/cypress/e2e/form-preview-a11y.cy.js | 2 +- .../e2e/form-preview-html-validation.cy.js | 4 +- tests/cypress/support/commands.js | 55 ++++++++++--- .../frm-protect-shared-plugin-files.php | 46 +++++++++++ 7 files changed, 168 insertions(+), 58 deletions(-) create mode 100644 tests/mu-plugins/frm-protect-shared-plugin-files.php diff --git a/classes/views/shared/toggle.php b/classes/views/shared/toggle.php index a4afa12813..f40c53b95e 100644 --- a/classes/views/shared/toggle.php +++ b/classes/views/shared/toggle.php @@ -26,6 +26,7 @@ $use_container = false; $aria_attrs = array(); + if ( ! empty( $args['aria-label-attr'] ) ) { $aria_attrs['aria-label'] = $args['aria-label-attr']; } else { diff --git a/tests/cypress/e2e/Forms/deleteForms.cy.js b/tests/cypress/e2e/Forms/deleteForms.cy.js index dbb1ced6a6..fca8a7f982 100644 --- a/tests/cypress/e2e/Forms/deleteForms.cy.js +++ b/tests/cypress/e2e/Forms/deleteForms.cy.js @@ -5,20 +5,34 @@ describe( 'Deleting forms', () => { cy.viewport( 1280, 720 ); } ); + // Select only the "Test Form" rows on whatever list table is currently showing, rather than + // the header "select all" checkbox - the forms list isn't necessarily empty going into this + // test (e.g. the "Contact Us" form Formidable creates by default on a fresh install), and a + // blanket "select all" would sweep that up into this test's own trash/delete/restore cycle. + const checkTestFormRows = () => { + cy.get( 'tr' ).filter( ( index, element ) => { + return Cypress.$( element ).text().includes( 'Test Form' ); + } ).each( $row => { + cy.wrap( $row ).find( '.check-column input[type="checkbox"]' ).check(); + } ); + }; + it( 'should create multiple forms and bulk delete them', () => { cy.emptyTrash(); + cy.log( 'Record the published form count so later assertions allow for forms this test did not create, like the default Contact Us form' ); + let baselinePublishedCount = '(0)'; + cy.get( '.published > a .count' ).invoke( 'text' ).then( text => { + baselinePublishedCount = text; + } ); + cy.log( 'Create 5 new forms' ); for ( let i = 0; i < 5; i++ ) { cy.createNewForm(); } cy.log( 'Bulk delete all 5 new forms' ); - cy.get( 'tr' ).filter( ( index, element ) => { - return Cypress.$( element ).text().includes( 'Test Form' ); - } ).each( $row => { - cy.wrap( $row ).find( '.check-column input[type="checkbox"]' ).check(); - } ); + checkTestFormRows(); cy.log( 'Click on Bulk Actions and select the Move to Trash option' ); cy.get( '#bulk-action-selector-top' ).select( 'Move to Trash' ); @@ -36,11 +50,8 @@ describe( 'Deleting forms', () => { cy.get( '.colspanchange > p' ).should( 'contain', 'No forms found in the trash.' ); cy.get( '.colspanchange > p > a' ).should( 'contain', 'See all forms' ).click(); cy.log( 'Bulk delete permanently deleted forms' ); - cy.get( '#bulk-action-selector-top' ).select( 'Move to Trash' ); - cy.get( '#doaction' ).should( 'contain', 'Apply' ).click(); - cy.get( '.trash > a' ).should( 'contain.text', 'Trash' ); - cy.get( '#cb-select-all-1' ).click(); - cy.get( '#bulk-action-selector-top' ).should( 'contain', 'Bulk Actions' ).select( 'bulk_trash' ); + checkTestFormRows(); + cy.get( '#bulk-action-selector-top' ).select( 'bulk_trash' ); cy.get( '#doaction' ).should( 'contain', 'Apply' ).click(); cy.log( 'Permanently delete 2 forms using the Delete Permanetly option' ); @@ -59,14 +70,18 @@ describe( 'Deleting forms', () => { cy.get( '.trash > a' ).should( 'contain.text', 'Trash' ) .find( '.count' ).should( 'contain.text', '(3)' ); cy.get( '.published > a' ).should( 'contain.text', 'My Forms' ) - .find( '.count' ).should( 'contain.text', '(0)' ); + .find( '.count' ).then( $count => { + expect( $count.text() ).to.eq( baselinePublishedCount ); + } ); cy.log( 'Empty trash' ); cy.get( '#delete_all' ).should( 'contain', 'Empty Trash' ).click(); cy.get( '.trash > a' ).should( 'contain.text', 'Trash' ) .find( '.count' ).should( 'contain.text', '(0)' ); cy.get( '.published > a' ).should( 'contain.text', 'My Forms' ) - .find( '.count' ).should( 'contain.text', '(0)' ); + .find( '.count' ).then( $count => { + expect( $count.text() ).to.eq( baselinePublishedCount ); + } ); cy.get( '.colspanchange > p' ).should( 'contain', 'No forms found in the trash.' ); } ); } ); diff --git a/tests/cypress/e2e/Styles/sliderComponent.cy.js b/tests/cypress/e2e/Styles/sliderComponent.cy.js index 20ccf3bc1d..6c99c1ab56 100644 --- a/tests/cypress/e2e/Styles/sliderComponent.cy.js +++ b/tests/cypress/e2e/Styles/sliderComponent.cy.js @@ -2,17 +2,26 @@ describe( 'Slider style component', () => { beforeEach( () => { cy.login(); cy.viewport( 1280, 1600 ); + // Every change the tests below make gets committed to the hidden input by a "change" + // handler that runs immediately - but the style editor also fires an admin-ajax + // `frm_change_styling` live-preview request on the same change, and re-normalizes the + // hidden input's value (e.g. adding back the unit) once that request resolves. Firing + // several slider changes back to back without waiting for that request backs its queue + // up, so the value/disabled-state assertions that follow can outlast Cypress's default + // retry window - not because the value is wrong, but because it hasn't settled yet. + // The action lives in the POST body, not the URL, so match on the body rather than a URL + // pattern. + cy.intercept( 'POST', '**/admin-ajax.php', req => { + if ( req.body?.includes( 'action=frm_change_styling' ) ) { + req.alias = 'changeStyling'; + } + } ); } ); - // Every change the tests below make gets committed to the hidden input by a "change" handler - // that runs immediately - but the style editor also fires an admin-ajax live-preview request - // on the same change, and re-normalizes the hidden input's value (e.g. adding back the unit) - // once that request resolves. Firing several slider changes back to back without giving that - // request a moment to resolve backs its queue up, so the value/disabled-state assertions that - // follow can outlast Cypress's default 4000ms retry window - not because the value is wrong, - // but because it hasn't settled yet. Call this after every blur()/select() on a slider. const SLIDER_TIMEOUT = 10000; - const settleSliderChange = () => cy.wait( 500 ); + // Wait for the live-preview sync request to actually round-trip, rather than a fixed delay. + // Call this after every blur()/select() on a slider. + const settleSliderChange = () => cy.wait( '@changeStyling', { timeout: SLIDER_TIMEOUT } ); /** * Locates a single (non multi-value) slider's range input, visible text input, unit select @@ -24,11 +33,15 @@ describe( 'Slider style component', () => { const getSingleSlider = hiddenId => { const wrapper = () => cy.get( hiddenId, { timeout: SLIDER_TIMEOUT } ).closest( '.frm-slider-component' ); + // Functions, not pre-built chains: a chain saved to a variable and asserted on much later + // (after other commands have queued in between, e.g. a select()/blur() and a settle wait) + // does not reliably re-run its full cy.get().closest().find() from the current DOM on + // retry - callers should invoke these at the point of use instead. return { - range: wrapper().find( 'input[type="range"]' ), - text: wrapper().find( '.frm-slider-value input[type="text"]' ), - select: wrapper().find( '.frm-slider-value select' ), - hidden: cy.get( hiddenId, { timeout: SLIDER_TIMEOUT } ) + range: () => wrapper().find( 'input[type="range"]' ), + text: () => wrapper().find( '.frm-slider-value input[type="text"]' ), + select: () => wrapper().find( '.frm-slider-value select' ), + hidden: () => cy.get( hiddenId, { timeout: SLIDER_TIMEOUT } ) }; }; @@ -60,21 +73,21 @@ describe( 'Slider style component', () => { const slider = getSingleSlider( '#frm_fieldset' ); // Max value for Border Width is 25 - start from a known, valid value. - slider.text.clear().type( '10' ).blur(); + slider.text().clear().type( '10' ).blur(); settleSliderChange(); - slider.hidden.should( 'have.value', '10px' ); + slider.hidden().should( 'have.value', '10px' ); cy.log( 'A value above the max is rejected and the text box resyncs to the current range value' ); - slider.text.clear().type( '999' ).blur(); + slider.text().clear().type( '999' ).blur(); settleSliderChange(); - slider.text.should( 'have.value', '10' ); - slider.hidden.should( 'have.value', '10px' ); + slider.text().should( 'have.value', '10' ); + slider.hidden().should( 'have.value', '10px' ); cy.log( 'A negative value is rejected the same way' ); - slider.text.clear().type( '-5' ).blur(); + slider.text().clear().type( '-5' ).blur(); settleSliderChange(); - slider.text.should( 'have.value', '10' ); - slider.hidden.should( 'have.value', '10px' ); + slider.text().should( 'have.value', '10' ); + slider.hidden().should( 'have.value', '10px' ); } ); it( 'Expands a multi-value slider group and lets an individual slider be adjusted independently', () => { @@ -118,20 +131,21 @@ describe( 'Slider style component', () => { const slider = getSingleSlider( '#frm_submit_width' ); cy.log( 'Force the unit to "auto" and confirm the range and text box are both disabled, with no duplicated "auto" text' ); - slider.select.select( 'auto' ); - settleSliderChange(); - slider.range.should( 'be.disabled' ); - slider.range.should( 'have.attr', 'aria-valuetext', 'auto' ); - slider.text.should( 'be.disabled' ).and( 'have.value', '' ); - slider.hidden.should( 'have.value', 'auto' ); + slider.select().select( 'auto' ); + // Wait for the wrapper's own disabled-state class before inspecting its children - + // applying "auto" disables the range/text inputs via the same handler that adds this + // class, so this is a real signal that handler has run rather than an arbitrary pause. cy.get( '#frm_submit_width' ).closest( '.frm-slider-component' ).should( 'have.class', 'frm-disabled' ); + slider.range().should( 'be.disabled' ); + slider.range().should( 'have.attr', 'aria-valuetext', 'auto' ); + slider.text().should( 'be.disabled' ).and( 'have.value', '' ); + slider.hidden().should( 'have.value', 'auto' ); cy.log( 'Switching to a measured unit re-enables both the range and the text box' ); - slider.select.select( 'px' ); - settleSliderChange(); - slider.range.should( 'not.be.disabled' ); - slider.text.should( 'not.be.disabled' ); - slider.hidden.invoke( 'val' ).should( 'match', /^\d+px$/ ); + slider.select().select( 'px' ); + slider.range().should( 'not.be.disabled' ); + slider.text().should( 'not.be.disabled' ); + slider.hidden().invoke( 'val' ).should( 'match', /^\d+px$/ ); cy.get( '#frm_submit_width' ).closest( '.frm-slider-component' ).should( 'not.have.class', 'frm-disabled' ); cy.log( 'The numeric value survives a save' ); @@ -142,8 +156,7 @@ describe( 'Slider style component', () => { cy.log( 'Switching back to "auto" and saving persists the disabled, blank, unmeasured state too' ); cy.get( '#buttons-style button[aria-label="Buttons"]' ).click(); cy.get( '#frm_style_section_buttons-style' ).should( 'be.visible' ); - getSingleSlider( '#frm_submit_width' ).select.select( 'auto' ); - settleSliderChange(); + getSingleSlider( '#frm_submit_width' ).select().select( 'auto' ); cy.get( '#frm_submit_side_top' ).click( { force: true } ); cy.get( '#frm_submit_width' ).should( 'have.value', 'auto' ); diff --git a/tests/cypress/e2e/form-preview-a11y.cy.js b/tests/cypress/e2e/form-preview-a11y.cy.js index 1cf6e9a068..464f2f4f4d 100644 --- a/tests/cypress/e2e/form-preview-a11y.cy.js +++ b/tests/cypress/e2e/form-preview-a11y.cy.js @@ -15,7 +15,7 @@ describe( 'Run some accessibility tests', function() { it( 'Check the form list has valid HTML', () => { cy.login(); cy.ensureContactUsFormExists(); - cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-form' ); + cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-us' ); cy.injectAxe(); configureAxeWithBaselineIgnoredRuleset(); cy.checkA11y(); diff --git a/tests/cypress/e2e/form-preview-html-validation.cy.js b/tests/cypress/e2e/form-preview-html-validation.cy.js index fe5eb826ce..6495d60c67 100644 --- a/tests/cypress/e2e/form-preview-html-validation.cy.js +++ b/tests/cypress/e2e/form-preview-html-validation.cy.js @@ -2,7 +2,7 @@ describe( 'Run some HTML validation', function() { it( 'Check the form list has valid HTML', () => { cy.login(); cy.ensureContactUsFormExists(); - cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-form' ); - cy.get( '#form_contact-form' ).htmlvalidate(); + cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-us' ); + cy.get( '#form_contact-us' ).htmlvalidate(); } ); } ); diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index f49fdf1107..6301340f69 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -77,15 +77,42 @@ Cypress.Commands.add( 'createNewForm', () => { } ); /** - * Ensure the "Contact Us" template form (frm_key `contact-form`) exists, creating it if needed. + * Ensure the "Contact Us" template form (frm_key `contact-us`) exists and is previewable, + * creating and/or restoring it as needed. * * Several specs preview this form directly by key without creating it themselves, relying on * `Form Templates/FormTemplates.cy.js` having already created it in the same wp-env instance. * That only holds when both specs land in the same CI shard, which isn't guaranteed - shards are * bin-packed by spec file line count (see tests/bin/split-specs.sh), so adding or resizing any * spec file can split them apart. Call this instead of assuming the fixture is already there. + * + * The remote template (fetched from S3 by "Use Template") ships with `status=trash` baked into + * its own XML, so a freshly installed copy lands in the Trash and isn't previewable until it's + * restored - and re-running "Use Template" against an already-trashed copy just creates another + * trashed one with a suffixed key (`contact-us2`, `contact-us3`, ...) instead of reusing it. Check + * Trash before creating, and restore whatever copy ends up there instead of leaving it stuck. */ Cypress.Commands.add( 'ensureContactUsFormExists', () => { + const RESTORE_LINK_SELECTOR = '#the-list tr:contains("Contact Us") a.frm-trash-link[href*="frm_action=untrash"]'; + + // Split the "is it there" check from the "click to restore" step - a .then() callback that + // queues cy commands (the click) can't also return a plain sync value (the found/not-found + // boolean) in the same callback, so each concern gets its own .then(). + const restoreFromTrash = () => { + cy.visit( '/wp-admin/admin.php?page=formidable&form_type=trash' ); + return cy.get( 'body' ) + .then( $trashBody => 0 < $trashBody.find( RESTORE_LINK_SELECTOR ).length ) + .then( found => { + if ( ! found ) { + return cy.wrap( false ); + } + + cy.log( 'Restore the Contact Us form out of Trash instead of leaving it stuck there' ); + cy.get( RESTORE_LINK_SELECTOR ).first().click( { force: true } ); + return cy.wrap( true ); + } ); + }; + cy.visit( '/wp-admin/admin.php?page=formidable' ); cy.get( 'body' ).then( $body => { if ( $body.find( '#the-list tr:contains("Contact Us")' ).length > 0 ) { @@ -93,16 +120,24 @@ Cypress.Commands.add( 'ensureContactUsFormExists', () => { return; } - cy.log( 'Create the Contact Us form from its template' ); - cy.visit( '/wp-admin/admin.php?page=formidable-form-templates' ); - cy.contains( 'li', 'Contact Us', { timeout: 10000 } ) - .first() - .trigger( 'mouseover', { force: true } ) - .find( '.frm-form-templates-use-template-button' ) - .should( 'contain', 'Use Template' ) - .click( { force: true } ); + restoreFromTrash().then( restored => { + if ( restored ) { + return; + } + + cy.log( 'Create the Contact Us form from its template' ); + cy.visit( '/wp-admin/admin.php?page=formidable-form-templates' ); + cy.contains( 'li', 'Contact Us', { timeout: 10000 } ) + .first() + .trigger( 'mouseover', { force: true } ) + .find( '.frm-form-templates-use-template-button' ) + .should( 'contain', 'Use Template' ) + .click( { force: true } ); - cy.get( "svg[aria-label='Close']", { timeout: 7000 } ).click( { force: true } ); + cy.get( "svg[aria-label='Close']", { timeout: 7000 } ).click( { force: true } ); + + restoreFromTrash(); + } ); } ); } ); diff --git a/tests/mu-plugins/frm-protect-shared-plugin-files.php b/tests/mu-plugins/frm-protect-shared-plugin-files.php new file mode 100644 index 0000000000..9de0ca7d04 --- /dev/null +++ b/tests/mu-plugins/frm-protect-shared-plugin-files.php @@ -0,0 +1,46 @@ + array(), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => null, + ); + }, + 10, + 3 +); From 02715abc8964be1d09e7d8933fce4f44cd0f680f Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Fri, 18 Sep 2026 14:07:40 -0300 Subject: [PATCH 3/6] Use the other form key --- tests/cypress/e2e/form-preview-a11y.cy.js | 2 +- tests/cypress/e2e/form-preview-html-validation.cy.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cypress/e2e/form-preview-a11y.cy.js b/tests/cypress/e2e/form-preview-a11y.cy.js index 464f2f4f4d..1cf6e9a068 100644 --- a/tests/cypress/e2e/form-preview-a11y.cy.js +++ b/tests/cypress/e2e/form-preview-a11y.cy.js @@ -15,7 +15,7 @@ describe( 'Run some accessibility tests', function() { it( 'Check the form list has valid HTML', () => { cy.login(); cy.ensureContactUsFormExists(); - cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-us' ); + cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-form' ); cy.injectAxe(); configureAxeWithBaselineIgnoredRuleset(); cy.checkA11y(); diff --git a/tests/cypress/e2e/form-preview-html-validation.cy.js b/tests/cypress/e2e/form-preview-html-validation.cy.js index 6495d60c67..2118eca615 100644 --- a/tests/cypress/e2e/form-preview-html-validation.cy.js +++ b/tests/cypress/e2e/form-preview-html-validation.cy.js @@ -2,7 +2,7 @@ describe( 'Run some HTML validation', function() { it( 'Check the form list has valid HTML', () => { cy.login(); cy.ensureContactUsFormExists(); - cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-us' ); + cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-form' ); cy.get( '#form_contact-us' ).htmlvalidate(); } ); } ); From e32a398f450d6460cbb1af89c91f766a0e65d0e8 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Fri, 18 Sep 2026 14:08:53 -0300 Subject: [PATCH 4/6] Use the other form key --- tests/cypress/e2e/form-preview-html-validation.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cypress/e2e/form-preview-html-validation.cy.js b/tests/cypress/e2e/form-preview-html-validation.cy.js index 2118eca615..fe5eb826ce 100644 --- a/tests/cypress/e2e/form-preview-html-validation.cy.js +++ b/tests/cypress/e2e/form-preview-html-validation.cy.js @@ -3,6 +3,6 @@ describe( 'Run some HTML validation', function() { cy.login(); cy.ensureContactUsFormExists(); cy.visit( '/wp-admin/admin-ajax.php?action=frm_forms_preview&form=contact-form' ); - cy.get( '#form_contact-us' ).htmlvalidate(); + cy.get( '#form_contact-form' ).htmlvalidate(); } ); } ); From e2c606e6d079438ffb86f4f5393604c000164780 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Fri, 18 Sep 2026 15:24:20 -0300 Subject: [PATCH 5/6] Fix e2e test flakiness in Forms/Entries/Form Templates specs Replace forced clicks on hover-only revealed elements (row-actions, field action icons, template card buttons) with real CSS-reveal waits instead of {force: true}, and make duplicateForm.cy.js's teardown run in afterEach so a mid-test failure doesn't leak a stray "Test Form" into later specs. Co-Authored-By: Claude Sonnet 5 --- .../Entries/EntriesPageDataValidations.cy.js | 20 ++- tests/cypress/e2e/Entries/deleteEntries.cy.js | 23 ++- .../e2e/Form Templates/FormTemplates.cy.js | 130 +++++++++++------ tests/cypress/e2e/Forms/deleteForms.cy.js | 6 +- tests/cypress/e2e/Forms/duplicateForm.cy.js | 38 +++-- .../e2e/Forms/fieldsInFormBuilder.cy.js | 134 ++++++++++++------ tests/cypress/e2e/Forms/formsSettings.cy.js | 12 +- .../e2e/GlobalSettings/customCssScoping.cy.js | 2 +- .../globalSettingsUpdates.cy.js | 5 +- .../cypress/e2e/Styles/sliderComponent.cy.js | 33 +++-- tests/cypress/e2e/admin-a11y.cy.js | 59 ++++++-- tests/cypress/e2e/admin-html-validation.cy.js | 6 + tests/cypress/e2e/form-preview-a11y.cy.js | 4 +- .../e2e/form-preview-html-validation.cy.js | 4 + tests/cypress/support/commands.js | 42 +++++- .../frm-protect-shared-plugin-files.php | 4 +- 16 files changed, 376 insertions(+), 146 deletions(-) diff --git a/tests/cypress/e2e/Entries/EntriesPageDataValidations.cy.js b/tests/cypress/e2e/Entries/EntriesPageDataValidations.cy.js index 134c5f4ffd..5aa3824d95 100644 --- a/tests/cypress/e2e/Entries/EntriesPageDataValidations.cy.js +++ b/tests/cypress/e2e/Entries/EntriesPageDataValidations.cy.js @@ -14,7 +14,8 @@ describe( 'Entries submitted from a form', () => { cy.get( '#frm-save-form-name-button' ).should( 'contain', 'Save' ).click(); cy.log( `Create a text field` ); - cy.get( `li[id="text"] a[title="Text"]` ).click( { force: true } ); + // Plain, always-visible sidebar link - no hover gating involved. + cy.get( `li[id="text"] a[title="Text"]` ).should( 'be.visible' ).click(); cy.log( 'Update form' ); cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click(); @@ -74,7 +75,7 @@ describe( 'Entries submitted from a form', () => { } ); cy.get( '.frm_form_nav > :nth-child(1) > a' ).should( 'contain', 'Build' ).click(); - cy.get( "a[aria-label='Close']", { timeout: 5000 } ).click( { force: true } ); + cy.get( "a[aria-label='Close']", { timeout: 5000 } ).should( 'be.visible' ).click(); cy.log( 'Verify that entries are not allowed from the forms list' ); cy.get( 'td[data-colname="Entries"] svg[title="Saving entries is disabled for this form"]' ).should( 'exist' ); } ); @@ -88,7 +89,8 @@ describe( 'Entries submitted from a form', () => { cy.get( '#frm-save-form-name-button' ).should( 'contain', 'Save' ).click(); cy.log( `Create a text field` ); - cy.get( `li[id="text"] a[title="Text"]` ).click( { force: true } ); + // Plain, always-visible sidebar link - no hover gating involved. + cy.get( `li[id="text"] a[title="Text"]` ).should( 'be.visible' ).click(); cy.log( 'Update form' ); cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click(); @@ -130,9 +132,15 @@ describe( 'Entries submitted from a form', () => { cy.get( 'td[data-colname="IP"]' ).should( 'exist' ); cy.log( 'Click on View' ); - cy.get( 'tr div.row-actions span.view a', { timeout: 5000 } ) + // WP core only reveals row-actions on a real CSS `:hover` (`.row-actions` is + // `position: relative; left: -9999em` until `tr:hover`) - make it actionable the way the + // real hover would, then click normally. Chained in one continuous command so there's no + // window between the reset and the click for a re-render to undo it. + cy.get( 'tr div.row-actions', { timeout: 5000 } ) + .invoke( 'css', 'position', 'static' ) + .find( 'span.view a' ) .should( 'contain', 'View' ) - .click( { force: true } ); + .click(); cy.url().should( 'include', 'frm_action=show&id=' ); @@ -188,7 +196,7 @@ describe( 'Entries submitted from a form', () => { cy.get( '.frm_no_entries_header' ).should( 'contain', 'No Entries for form: Test Form' ); cy.get( '.frm_form_nav > :nth-child(1) > a' ).should( 'contain', 'Build' ).click(); - cy.get( "a[aria-label='Close']", { timeout: 5000 } ).click( { force: true } ); + cy.get( "a[aria-label='Close']", { timeout: 5000 } ).should( 'be.visible' ).click(); } ); afterEach( () => { diff --git a/tests/cypress/e2e/Entries/deleteEntries.cy.js b/tests/cypress/e2e/Entries/deleteEntries.cy.js index 30d5ed08fc..c3de7144a1 100644 --- a/tests/cypress/e2e/Entries/deleteEntries.cy.js +++ b/tests/cypress/e2e/Entries/deleteEntries.cy.js @@ -14,11 +14,12 @@ describe( 'Entries submitted from a form', () => { cy.get( '#frm-save-form-name-button' ).should( 'contain', 'Save' ).click(); cy.log( `Add some fields` ); - cy.get( 'li[id="text"] a[title="Text"]' ).click( { force: true } ); - cy.get( 'li[id="name"] a[title="Name"]' ).click( { force: true } ); - cy.get( 'li[id="checkbox"] a[title="Checkboxes"]' ).click( { force: true } ); - cy.get( 'li[id="email"] a[title="Email"]' ).click( { force: true } ); - cy.get( 'li[id="phone"] a[title="Phone"]' ).click( { force: true } ); + // Plain, always-visible sidebar links - no hover gating involved. + cy.get( 'li[id="text"] a[title="Text"]' ).should( 'be.visible' ).click(); + cy.get( 'li[id="name"] a[title="Name"]' ).should( 'be.visible' ).click(); + cy.get( 'li[id="checkbox"] a[title="Checkboxes"]' ).should( 'be.visible' ).click(); + cy.get( 'li[id="email"] a[title="Email"]' ).should( 'be.visible' ).click(); + cy.get( 'li[id="phone"] a[title="Phone"]' ).should( 'be.visible' ).click(); cy.log( 'Update form' ); cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click(); @@ -55,7 +56,15 @@ describe( 'Entries submitted from a form', () => { cy.go( 'back' ); cy.contains( '#the-list tr', 'Entry test' ).trigger( 'mouseover' ).then( $row => { cy.wrap( $row ).within( () => { - cy.get( '.row-actions .delete .submitdelete' ).should( 'be.visible' ).click( { force: true } ); + // WP core only reveals row-actions on a real CSS `:hover` (`.row-actions` is + // `position: relative; left: -9999em` until `tr:hover`) - make it actionable the + // way the real hover would, then click normally. Chained in one continuous command + // so there's no window between the reset and the click for a re-render to undo it. + cy.get( '.row-actions' ) + .invoke( 'css', 'position', 'static' ) + .find( '.delete .submitdelete' ) + .should( 'be.visible' ) + .click(); } ); } ); cy.contains( '.frm-confirm-msg', 'Permanently delete this entry?' ); @@ -63,7 +72,7 @@ describe( 'Entries submitted from a form', () => { cy.log( 'Teardown - Close and delete form' ); cy.get( '.frm_form_nav > :nth-child(1) > a' ).should( 'contain', 'Build' ).click(); - cy.get( "a[aria-label='Close']", { timeout: 5000 } ).click( { force: true } ); + cy.get( "a[aria-label='Close']", { timeout: 5000 } ).should( 'be.visible' ).click(); cy.deleteForm(); } ); } ); diff --git a/tests/cypress/e2e/Form Templates/FormTemplates.cy.js b/tests/cypress/e2e/Form Templates/FormTemplates.cy.js index c49f8d1f89..1979e38b82 100644 --- a/tests/cypress/e2e/Form Templates/FormTemplates.cy.js +++ b/tests/cypress/e2e/Form Templates/FormTemplates.cy.js @@ -261,11 +261,16 @@ describe( 'Form Templates page', () => { it( 'add templates as favorites, view demo and use templates', () => { cy.log( 'Add contact us template as favorite' ); + // Verified via Playwright: `.frm-form-templates-item-favorite-button` is `display: none` + // until the card is hovered (a real CSS `:hover`, which Cypress can't simulate before its + // own pre-click check) - make it actionable the way the real hover would, then click + // normally. cy.contains( 'li', 'Contact Us', { timeout: 10000 } ) .first() - .trigger( 'mouseover', { force: true } ) + .should( 'be.visible' ) .find( '.frm-form-templates-item-favorite-button' ) - .click( { force: true } ); + .invoke( 'css', 'display', 'flex' ) + .click(); cy.get( '[data-category="favorites"] > .frm-page-skeleton-cat-count' ).should( 'contain.text', '1' ); cy.get( '[data-category="favorites"]' ).click(); @@ -278,13 +283,20 @@ describe( 'Form Templates page', () => { cy.get( '[data-category="all-items"]' ).should( 'contain', 'All Templates' ).click(); cy.log( 'View demo of the contact us template' ); + // Verified via Playwright: the button row (`.frm-form-templates-item-buttons`) is + // `display: none` until the card is hovered - make it actionable the way the real hover + // would, then click normally. + cy.contains( 'li', 'Contact Us', { timeout: 10000 } ) + .first() + .should( 'be.visible' ) + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ); cy.contains( 'li', 'Contact Us', { timeout: 10000 } ) .first() - .trigger( 'mouseover', { force: true } ) .find( '.frm-button-secondary' ) .invoke( 'removeAttr', 'target' ) .should( 'contain', 'View Demo' ) - .click( { force: true } ); + .click(); cy.origin( 'https://formidableforms.com', () => { cy.get( 'h1' ).should( 'have.text', 'Contact Us Form Template' ); @@ -292,33 +304,38 @@ describe( 'Form Templates page', () => { cy.visit( '/wp-admin/admin.php?page=formidable-form-templates' ); - cy.contains( 'li', 'Contact Us', { timeout: 10000 } ) - .first() - .trigger( 'mouseover', { force: true } ) - .find( '.frm-form-templates-use-template-button' ) - .should( 'contain', 'Use Template' ); - // cy.log( 'Try to use available templates' ); // cy.get( '[data-category="available-templates"]' ).should( 'contain', 'Available Templates' ).click(); // cy.get( '#frm-form-templates-page-title-text' ).should( 'contain', 'Available Templates' ); + // Invoke display:flex and click in one continuous chain, right before the click - a + // separate cy.contains() re-query for the click (as this used to do) leaves a window where + // a React re-render of this list can restore its own controlled style and re-hide the + // button before the click actually lands. cy.contains( 'li', 'Contact Us', { timeout: 10000 } ) .first() - .trigger( 'mouseover', { force: true } ) + .should( 'be.visible' ) + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ) .find( '.frm-form-templates-use-template-button' ) .should( 'contain', 'Use Template' ) - .click( { force: true } ); + .click(); - cy.get( 'svg[aria-label="Close"]' ).click( { force: true } ); + cy.get( 'svg[aria-label="Close"]' ).should( 'be.visible' ).click(); cy.visit( '/wp-admin/admin.php?page=formidable-form-templates' ); + // Same real CSS `:hover` reveal on the template card as above - make it actionable the + // way the real hover would, then click normally. + cy.get( '[frm-search-text="user registration"]' ) + .first() + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ); cy.get( '[frm-search-text="user registration"]' ) .first() - .trigger( 'mouseover' ) .find( '.frm-form-templates-use-template-button' ) .should( 'contain', 'Use Template' ) - .click( { force: true } ); + .click(); cy.get( '#frm-form-upgrade-modal > .frm_modal_top > .frm-modal-title > h2' ).should( 'contain', 'User Registration is a PRO Template' ); cy.get( '#frm-form-upgrade-modal > .inside > :nth-child(1)' ).should( 'contain', 'The User Registration is not available on your plan. Please upgrade to unlock this and more awesome templates.' ); @@ -331,12 +348,14 @@ describe( 'Form Templates page', () => { } ); it( 'create a new custom template and delete it', () => { + // Plain, always-visible button - no force needed (an identical unforced click on this + // selector works in commands.js's createNewForm()); Cypress auto-scrolls into view. cy.get( '#frm-form-templates-create-form' ) .should( 'contain', 'Create a blank form' ) - .scrollIntoView() - .click( { force: true } ); + .click(); - cy.get( '#frm_submit_side_top' ).should( 'contain', 'Save' ).click( { force: true } ); + // Plain #frm_submit_side_top "Save" click - no force needed, see the note above. + cy.get( '#frm_submit_side_top' ).should( 'contain', 'Save' ).click(); cy.log( 'Ensure the modal for saving form is visible' ); cy.get( '#frm-form-templates-modal' ).should( 'be.visible' ); @@ -355,10 +374,11 @@ describe( 'Form Templates page', () => { .should( 'contain.text', 'Cancel' ); cy.get( '#frm_new_form_name_input' ).type( 'Form Template Test' ); - cy.get( '#frm-save-form-name-button' ).should( 'contain', 'Save' ).click( { force: true } ); + // Plain #frm-save-form-name-button click - no force needed (see commands.js's createNewForm()). + cy.get( '#frm-save-form-name-button' ).should( 'contain', 'Save' ).click(); cy.get( "a[aria-label='Close'] svg", { timeout: 10000 } ) .should( 'be.visible' ) - .click( { force: true } ); + .click(); cy.get( '#toplevel_page_formidable > .wp-submenu > :nth-child(8) > a' ).should( 'contain', 'Form Templates' ).click(); cy.get( '[data-category="custom"]' ).click(); @@ -366,20 +386,20 @@ describe( 'Form Templates page', () => { cy.log( 'Validate that there are no custom templates yet' ); cy.get( '.frmcenter > .frm-page-skeleton-title' ).should( 'contain', 'You currently have no templates.' ); cy.get( '.frm-page-skeleton-text' ).should( 'contain', "You haven't created any form templates. Begin now to simplify your workflow and save time." ); - cy.get( '#frm-page-skeleton-empty-state > .button' ).should( 'contain', 'Create Template' ).click( { force: true } ); + cy.get( '#frm-page-skeleton-empty-state > .button' ).should( 'be.visible' ).and( 'contain', 'Create Template' ).click(); cy.get( '#frm-create-template-modal > .frm_modal_footer > .button-secondary' ).should( 'contain', 'Cancel' ).click(); cy.log( 'Create a new template' ); - cy.get( '#frm-page-skeleton-empty-state > .button' ).should( 'contain', 'Create Template' ).click( { force: true } ); + cy.get( '#frm-page-skeleton-empty-state > .button' ).should( 'be.visible' ).and( 'contain', 'Create Template' ).click(); cy.get( '#frm-create-template-modal > .frm_modal_top > .frm-modal-title > h2' ).should( 'contain', 'Create New Template' ); cy.get( '.inside > :nth-child(1) > label' ).should( 'contain', 'Select form for a new template' ); cy.get( '#frm-create-template-modal-forms-select' ).select( 'Form Template Test' ); cy.get( ':nth-child(3) > label' ).should( 'contain', 'Description' ); cy.get( '#frm_create_template_description' ).type( 'Test description' ); - cy.get( '#frm-create-template-button' ).should( 'contain', 'Create Template' ).click( { force: true } ); + cy.get( '#frm-create-template-button' ).should( 'be.visible' ).and( 'contain', 'Create Template' ).click(); cy.get( "a[aria-label='Close'] svg", { timeout: 10000 } ) .should( 'be.visible' ) - .click( { force: true } ); + .click(); cy.get( '.row-title' ).should( 'contain', 'Form Template Test Template' ); cy.get( '#toplevel_page_formidable > .wp-submenu > :nth-child(8) > a' ).should( 'contain', 'Form Templates' ).click(); @@ -392,43 +412,52 @@ describe( 'Form Templates page', () => { .should( 'contain', 'Test description' ); cy.log( 'Edit template' ); + // Same button-row `display: none` -> hover reveal as above - make it actionable the way + // the real hover would, then click normally. + cy.get( 'li[frm-search-text="form template test template"]' ) + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ); cy.get( 'li[frm-search-text="form template test template"]' ) - .trigger( 'mouseover' ) .find( '.frm-button-secondary' ) .should( 'contain', 'Edit' ) - .click( { force: true } ); + .click(); cy.get( "a[aria-label='Close'] svg", { timeout: 10000 } ) .should( 'be.visible' ) - .click( { force: true } ); + .click(); cy.get( '#toplevel_page_formidable > .wp-submenu > :nth-child(8) > a' ).should( 'contain', 'Form Templates' ).click(); cy.get( '[data-category="custom"]' ).click(); cy.log( 'Click on the use template button' ); + // Same button-row `display: none` -> hover reveal as above. + cy.get( 'li[frm-search-text="form template test template"]' ) + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ); cy.get( 'li[frm-search-text="form template test template"]' ) - .trigger( 'mouseover' ) .find( '.frm-button-primary' ) .should( 'contain', 'Use Template' ) - .click( { force: true } ); + .click(); cy.get( "a[aria-label='Close'] svg", { timeout: 10000 } ) .should( 'be.visible' ) - .click( { force: true } ); + .click(); cy.get( '#toplevel_page_formidable > .wp-submenu > :nth-child(8) > a' ).should( 'contain', 'Form Templates' ).click(); cy.get( '[data-category="custom"]' ).click(); cy.log( 'Delete template' ); + // This button is `display: none` until the card is hovered - make it actionable the way + // the real hover would, then click normally. cy.get( 'li[frm-search-text="form template test template"]' ) - .trigger( 'mouseover' ) .find( '.frm-form-templates-custom-item-trash-button' ) - .click( { force: true } ); + .invoke( 'css', 'display', 'flex' ) + .click(); cy.get( '.cta-inside > .frm-flex-box > .button-secondary' ).should( 'contain', 'Cancel' ).click(); cy.get( 'li[frm-search-text="form template test template"]' ) - .trigger( 'mouseover' ) .find( '.frm-form-templates-custom-item-trash-button' ) - .click( { force: true } ); + .invoke( 'css', 'display', 'flex' ) + .click(); cy.get( '.frm-confirm-msg' ).should( 'contain', 'Do you want to move this form template to the trash?' ); cy.get( '#frm-confirmed-click' ).should( 'contain', 'Confirm' ).click(); @@ -442,11 +471,14 @@ describe( 'Form Templates page', () => { cy.log( 'Get free templates by clicking Use Template' ); cy.get( '[data-category="all-items"]' ).should( 'contain', 'All Templates' ).click(); cy.get( '#frm-form-templates-page-title-text' ).should( 'contain', 'All Templates' ); + // Same button-row `display: none` -> hover reveal as above. + cy.get( '[frm-search-text="grade book"]' ) + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ); cy.get( '[frm-search-text="grade book"]' ) - .trigger( 'mouseover' ) .find( '.frm-form-templates-use-template-button' ) .should( 'contain', 'Use Template' ) - .click( { force: true } ); + .click(); cy.get( '#frm-leave-email-modal' ).should( 'be.visible' ); cy.get( '#frm-leave-email-modal > .frm_modal_top > .frm-modal-title > h2' ).should( 'contain', 'Get 30+ Free Form Templates' ); @@ -458,10 +490,12 @@ describe( 'Form Templates page', () => { cy.get( '#frm-leave-email-modal > .frm_modal_footer > .button-secondary' ).click(); cy.get( '[frm-search-text="grade book"]' ) - .trigger( 'mouseover' ) + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ); + cy.get( '[frm-search-text="grade book"]' ) .find( '.frm-form-templates-use-template-button' ) .should( 'contain', 'Use Template' ) - .click( { force: true } ); + .click(); cy.get( 'a#frm-get-code-button' ).click(); @@ -470,10 +504,13 @@ describe( 'Form Templates page', () => { cy.log( 'Try to use free templates' ); cy.get( '[frm-search-text="employee referral"]' ) .first() - .trigger( 'mouseover' ) + .find( '.frm-form-templates-item-buttons' ) + .invoke( 'css', 'display', 'flex' ); + cy.get( '[frm-search-text="employee referral"]' ) + .first() .find( '.frm-form-templates-use-template-button' ) .should( 'contain', 'Use Template' ) - .click( { force: true } ); + .click(); cy.get( 'span[title]' ).should( 'contain', 'Employee Referral' ); cy.get( "a[aria-label='Close']", { timeout: 5000 } ).click(); @@ -481,12 +518,21 @@ describe( 'Form Templates page', () => { cy.contains( '#the-list tr', 'Employee Referral' ).trigger( 'mouseover' ).then( $row => { console.log( 'Hovered Row:', $row ); cy.wrap( $row ).within( () => { - cy.get( '.row-actions .trash .frm-trash-link' ).should( 'be.visible' ).click( { force: true } ); + // WP hides row-actions until a real CSS `:hover` - make it actionable the way the real + // hover would, then click normally. Chained in one continuous command so there's no + // window between the reset and the click for a re-render to undo it. + cy.get( '.row-actions' ) + .invoke( 'css', 'position', 'static' ) + .find( '.trash .frm-trash-link' ) + .should( 'be.visible' ) + .click(); } ); cy.get( 'body' ).then( $body => { if ( $body.find( "div[role='dialog']" ).length ) { cy.get( "div[role='dialog']" ).should( 'be.visible' ).and( 'contain.text', 'Do you want to move this form to the trash?' ); - cy.xpath( "//a[@id='frm-confirmed-click']" ).should( 'contain.text', 'Confirm' ).click( { force: true } ); + // Plain cy.get() by id rather than cy.xpath() - the xpath-resolved element doesn't + // re-query the same way on Cypress's retry; plain cy.get() works unforced elsewhere. + cy.get( '#frm-confirmed-click' ).should( 'contain.text', 'Confirm' ).click(); } else { cy.log( 'Dialog not found' ); } diff --git a/tests/cypress/e2e/Forms/deleteForms.cy.js b/tests/cypress/e2e/Forms/deleteForms.cy.js index fca8a7f982..84e5029f2b 100644 --- a/tests/cypress/e2e/Forms/deleteForms.cy.js +++ b/tests/cypress/e2e/Forms/deleteForms.cy.js @@ -63,8 +63,10 @@ describe( 'Deleting forms', () => { cy.get( '#bulk-action-selector-top' ).should( 'contain', 'Bulk Actions' ).select( 'bulk_delete' ); cy.get( '#doaction' ).should( 'contain', 'Apply' ).click(); cy.get( '.frm-confirm-msg' ).should( 'contain', 'ALL selected forms and their entries will be permanently deleted. Want to proceed?' ); - cy.contains( 'a.button-secondary', 'Cancel' ) - .click( { force: true } ); + // cy.contains() (not cy.get()) is required here - two confirm banners with their own + // `a.button-secondary` Cancel link both exist in the DOM at once, so a plain cy.get() + // matches 2 elements and cy.click() rejects a multi-element subject. + cy.contains( 'a.button-secondary', 'Cancel' ).should( 'be.visible' ).click(); cy.get( '#doaction' ).should( 'contain', 'Apply' ).click(); cy.get( '#frm-confirmed-click' ).should( 'contain', 'Confirm' ).click(); cy.get( '.trash > a' ).should( 'contain.text', 'Trash' ) diff --git a/tests/cypress/e2e/Forms/duplicateForm.cy.js b/tests/cypress/e2e/Forms/duplicateForm.cy.js index a94aedec4a..f8d3afd2c7 100644 --- a/tests/cypress/e2e/Forms/duplicateForm.cy.js +++ b/tests/cypress/e2e/Forms/duplicateForm.cy.js @@ -14,27 +14,45 @@ describe( 'Duplicating a form from the form list page', () => { cy.log( 'Find the visible element with class duplicate within the hovered row and click it' ); cy.wrap( $row ).within( () => { - cy.get( '.row-actions .duplicate .frm-trash-link' ).should( 'be.visible' ).click( { force: true } ); + // WP core only reveals row-actions on a real CSS `:hover` (`.row-actions` is + // `position: relative; left: -9999em` until `tr:hover`) - make it actionable the + // way the real hover would, then click normally. Chained in one continuous command + // so there's no window between the reset and the click for a re-render to undo it. + cy.get( '.row-actions' ) + .invoke( 'css', 'position', 'static' ) + .find( '.duplicate .frm-trash-link' ) + .should( 'be.visible' ) + .click(); } ); cy.get( "a[aria-label='Close']", { timeout: 5000 } ).click(); cy.log( 'Locate rows containing the text - Test Form and count them' ); cy.get( '#the-list tr:contains("Test Form")' ).then( $rows => { - expect( $rows.length ).to.equal( 2 ); + expect( $rows ).to.have.lengthOf( 2 ); } ); } ); + } ); - cy.log( 'Teardown - Delete Test Form and its duplicate' ); - cy.deleteForm(); + // A separate afterEach (rather than teardown steps at the end of the `it` block) so cleanup + // still runs even if the assertion above throws - otherwise a failed run leaves both the + // original and the duplicate "Test Form" behind for every later spec that assumes a clean + // list (see the cross-spec "Test Form" leakage this caused). + afterEach( () => { + cy.log( 'Teardown - Move every Test Form row to the trash, how many are left doesn\'t matter' ); + cy.visit( '/wp-admin/admin.php?page=formidable' ); + cy.get( 'body' ).then( $body => { + const testFormRows = $body.find( '#the-list tr' ).filter( ( _, element ) => Cypress.$( element ).text().includes( 'Test Form' ) ); - cy.log( 'Delete duplicated form' ); - cy.contains( '#the-list tr', 'Test Form' ).trigger( 'mouseover' ).then( $row => { - cy.wrap( $row ).within( () => { - cy.get( '.row-actions .trash .frm-trash-link' ).should( 'be.visible' ).click( { force: true } ); + if ( testFormRows.length === 0 ) { + return; + } + + cy.wrap( testFormRows ).each( $row => { + cy.wrap( $row ).find( '.check-column input[type="checkbox"]' ).check(); } ); - cy.get( "div[role='dialog']" ).should( 'contain', 'Do you want to move this form to the trash?' ); - cy.xpath( "//a[@id='frm-confirmed-click']" ).should( 'contain', 'Confirm' ).click( { force: true } ); + cy.get( '#bulk-action-selector-top' ).select( 'Move to Trash' ); + cy.get( '#doaction' ).should( 'contain', 'Apply' ).click(); } ); } ); } ); diff --git a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js index 5bfb99f95b..619475ab07 100644 --- a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js +++ b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js @@ -6,12 +6,32 @@ describe( 'Fields in the form builder', () => { cy.viewport( 1280, 720 ); } ); + // Shared by the tests below - the "Add Fields" sidebar icon is a plain, always-visible link + // with no hover gating, so a simple visibility wait is enough (no force needed). + const createField = ( fieldId, fieldType ) => { + cy.log( `Create a ${ fieldType } field` ); + cy.get( `li[id="${ fieldId }"] a[title="${ fieldType }"]` ).should( 'be.visible' ).click(); + }; + it( 'should create, duplicate a field from each type and delete them', () => { const createAndDuplicateField = ( fieldId, fieldType ) => { cy.log( `Create a ${ fieldType } field and duplicate it` ); cy.get( `li[id="${ fieldId }"] a[title="${ fieldType }"]` ).click(); - cy.get( `li[data-ftype="${ fieldId }"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons > .dropdown > .frm_bstooltip > .frmsvg > use`, { timeout: 10000 } ).click( { force: true } ); - cy.get( `li[data-ftype="${ fieldId }"] .frm_clone_field > span` ).should( 'contain', 'Duplicate' ).click( { force: true } ); + // .frm-field-action-icons is also .frm-show-hover (opacity: 0 by default; see + // resources/scss/admin/components/sorting/_sorting-display.scss), only revealed on a + // real CSS :hover of the field row or when the field is .selected - it's still genuinely + // clickable underneath, so reveal it the same way the row-actions helpers in commands.js + // do, instead of forcing through the opacity check. + cy.get( `li[data-ftype="${ fieldId }"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons`, { timeout: 10000 } ) + .invoke( 'css', 'opacity', 1 ) + .find( '.dropdown > .frm_bstooltip > .frmsvg > use' ) + .first() + .should( 'be.visible' ) + .click(); + // The dropdown menu opens via a Bootstrap JS toggle (a real click, not hover-gated), so + // once it's open the item is genuinely clickable - wait for it to be visible instead of + // forcing through the open transition. + cy.get( `li[data-ftype="${ fieldId }"] .frm_clone_field > span` ).should( 'be.visible' ).and( 'contain', 'Duplicate' ).click(); cy.get( `li[data-type="${ fieldId }"]` ).should( 'have.length', 2 ); const originalField = cy.get( `li[data-type="${ fieldId }"]:first` ); @@ -21,21 +41,30 @@ describe( 'Fields in the form builder', () => { const removeField = field => { field.within( () => { - cy.get( '.frm-field-action-icons .dropdown' ) - .trigger( 'mouseover' ); - - cy.get( '.frm-field-action-icons .dropdown .frm-hover-icon .frmsvg' ) - .click( { force: true } ); - + // Same .frm-show-hover opacity gate as the toggle above - reveal it first. + cy.get( '.frm-field-action-icons' ) + .invoke( 'css', 'opacity', 1 ) + .find( '.dropdown .frm-hover-icon .frmsvg' ) + .first() + .should( 'be.visible' ) + .click(); + + // The menu is open via the click above (not hover-gated), so wait for the item to + // be visible instead of forcing through the open transition. cy.get( '.frm-dropdown-menu .frm_delete_field' ) - .should( 'contain', 'Delete' ) - .click( { force: true } ); + .should( 'be.visible' ) + .and( 'contain', 'Delete' ) + .click(); } ); - cy.get( '.postbox a[id="frm-confirmed-click"]' ) - .contains( 'Confirm' ) + // Plain cy.get() by id (an id is unique) rather than cy.get().contains() - the latter + // can resolve to a narrower descendant node than the clickable link itself, which is + // what forced force here. Plain cy.get() on this id works unforced elsewhere in the + // suite. + cy.get( '#frm-confirmed-click' ) .should( 'be.visible' ) - .click( { force: true } ); + .and( 'contain', 'Confirm' ) + .click(); cy.get( `li[data-type="${ field }"]` ).should( 'not.exist' ); }; @@ -43,7 +72,9 @@ describe( 'Fields in the form builder', () => { cy.contains( '#the-list tr', 'Test Form' ).trigger( 'mouseover' ).then( $row => { cy.wrap( $row ).within( () => { cy.get( '.column-name .row-title' ).should( 'exist' ).and( 'be.visible' ).then( $elem => { - cy.wrap( $elem ).click( { force: true } ); + // Plain click - verified via document.elementFromPoint() that the link is the + // topmost element at its own coordinates, not covered by anything. No force needed. + cy.wrap( $elem ).click(); } ); } ); } ); @@ -80,16 +111,20 @@ describe( 'Fields in the form builder', () => { } ); it( 'should rename a field from each type', () => { - const createField = ( fieldId, fieldType ) => { - cy.log( `Create a ${ fieldType } field` ); - cy.get( `li[id="${ fieldId }"] a[title="${ fieldType }"]` ).click( { force: true } ); - }; - const renameField = ( fieldId, fieldType, fieldValue ) => { cy.log( `Rename a ${ fieldType } field` ); - cy.get( `li[data-ftype="${ fieldId }"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons > .dropdown > .frm_bstooltip > .frmsvg > use`, { timeout: 10000 } ).click( { force: true } ); - cy.get( `li[data-ftype="${ fieldId }"] .frm_select_field > span` ).should( 'contain', 'Field Settings' ).click( { force: true } ); - cy.get( `div[id^="frm-single-settings-"] input[value="${ fieldValue }"]`, { timeout: 10000 } ).should( 'be.visible' ).clear( { force: true } ).type( `${ fieldType } Updated`, { force: true } ); + // See the .frm-show-hover opacity note on the field-row "more options" toggle above. + cy.get( `li[data-ftype="${ fieldId }"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons`, { timeout: 10000 } ) + .invoke( 'css', 'opacity', 1 ) + .find( '.dropdown > .frm_bstooltip > .frmsvg > use' ) + .first() + .should( 'be.visible' ) + .click(); + cy.get( `li[data-ftype="${ fieldId }"] .frm_select_field > span` ).should( 'be.visible' ).and( 'contain', 'Field Settings' ).click(); + // The settings panel opens via a bounded jQuery slideDown() (admin.js) - wait for the + // input to be visible (Cypress retries until it has settled into the flow), then interact + // normally. + cy.get( `div[id^="frm-single-settings-"] input[value="${ fieldValue }"]`, { timeout: 10000 } ).should( 'be.visible' ).clear().type( `${ fieldType } Updated` ); }; cy.openForm(); @@ -121,16 +156,18 @@ describe( 'Fields in the form builder', () => { it( 'should set fields as required and validate them in frontend', () => { const fieldTypes = [ 'Text', 'Paragraph', 'Checkboxes', 'Radio Buttons', 'Dropdown', 'Email', 'Website/URL', 'Number', 'Name', 'Phone' ]; - const createField = ( fieldId, fieldType ) => { - cy.log( `Create a ${ fieldType } field` ); - cy.get( `li[id="${ fieldId }"] a[title="${ fieldType }"]` ).click( { force: true } ); - }; - const requiredField = ( fieldId, fieldType ) => { cy.log( `Set ${ fieldType } field as require` ); - cy.get( `li[data-ftype="${ fieldId }"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons > .dropdown > .frm_bstooltip > .frmsvg > use`, { timeout: 10000 } ).click( { force: true } ); - cy.get( `li[data-ftype="${ fieldId }"] .frm_select_field > span` ).should( 'contain', 'Field Settings' ).click( { force: true } ); - cy.get( 'input.frm_req_field[type="checkbox"]' ).check( { force: true } ); + // See the .frm-show-hover opacity note on the field-row "more options" toggle above. + cy.get( `li[data-ftype="${ fieldId }"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons`, { timeout: 10000 } ) + .invoke( 'css', 'opacity', 1 ) + .find( '.dropdown > .frm_bstooltip > .frmsvg > use' ) + .first() + .should( 'be.visible' ) + .click(); + cy.get( `li[data-ftype="${ fieldId }"] .frm_select_field > span` ).should( 'be.visible' ).and( 'contain', 'Field Settings' ).click(); + // Same slideDown()-driven settings panel as elsewhere in this file. + cy.get( 'input.frm_req_field[type="checkbox"]' ).should( 'be.visible' ).check(); }; cy.openForm(); @@ -154,7 +191,9 @@ describe( 'Fields in the form builder', () => { } ); cy.log( 'Update form' ); - cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); + // Plain #frm_submit_side_top "Update" click - no force needed here (see the identical + // unforced click used for this same button elsewhere in the suite, e.g. formsSettings.cy.js). + cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click(); cy.log( 'Click on Preview - Blank Page' ); cy.get( '#frm-previewDrop', { timeout: 5000 } ).should( 'contain', 'Preview' ).click(); @@ -190,23 +229,36 @@ describe( 'Fields in the form builder', () => { it( 'should validate forms with javascript setting', () => { cy.openForm(); cy.log( `Create a text field and set it as required` ); - cy.get( `li[id="text"] a[title="Text"]` ).click( { force: true } ); - cy.get( `li[data-ftype="text"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons > .dropdown > .frm_bstooltip > .frmsvg > use`, { timeout: 10000 } ).click( { force: true } ); - cy.get( `li[data-ftype="text"] .frm_select_field > span` ).should( 'contain', 'Field Settings' ).click( { force: true } ); - cy.get( '.frm_field_list div[id^="frm-single-settings-"] .frm_grid_container .frm-hide-empty input[type="checkbox"]', { timeout: 10000 } ).check( { force: true } ); + // Plain, always-visible sidebar link - see the identical unforced click elsewhere in this file. + cy.get( `li[id="text"] a[title="Text"]` ).should( 'be.visible' ).click(); + // See the .frm-show-hover opacity note on the field-row "more options" toggle elsewhere in + // this file. + cy.get( `li[data-ftype="text"] [id^="field_"][id$="_inner_container"] > .frm-field-action-icons`, { timeout: 10000 } ) + .invoke( 'css', 'opacity', 1 ) + .find( '.dropdown > .frm_bstooltip > .frmsvg > use' ) + .first() + .should( 'be.visible' ) + .click(); + cy.get( `li[data-ftype="text"] .frm_select_field > span` ).should( 'be.visible' ).and( 'contain', 'Field Settings' ).click(); + // Same slideDown()-driven settings panel as elsewhere in this file. + cy.get( '.frm_field_list div[id^="frm-single-settings-"] .frm_grid_container .frm-hide-empty input[type="checkbox"]', { timeout: 10000 } ).should( 'be.visible' ).check(); cy.log( 'Create a phone and email field' ); - cy.get( `li[id="email"] a[title="Email"]` ).click( { force: true } ); - cy.get( `li[id="phone"] a[title="Phone"]` ).click( { force: true } ); + cy.get( `li[id="email"] a[title="Email"]` ).should( 'be.visible' ).click(); + cy.get( `li[id="phone"] a[title="Phone"]` ).should( 'be.visible' ).click(); cy.log( 'Update form' ); - cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); + // Plain #frm_submit_side_top "Update" click - no force needed, see the note above. + cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click(); cy.log( "Enabling the 'Validate this form with javascript' setting" ); cy.xpath( "//ul[@class='frm_form_nav']//a[contains(text(),'Settings')]" ).should( 'contain', 'Settings' ).click(); cy.get( ':nth-child(3) > td > .frm_inline_block', { timeout: 5000 } ).should( 'contain', 'Validate this form with javascript' ); - cy.get( '#js_validate' ).click( { force: true } ); - cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); + // This checkbox's own