diff --git a/.dev/tests/cypress/helpers.js b/.dev/tests/cypress/helpers.js index a13da295d7a..4ebc7680023 100644 --- a/.dev/tests/cypress/helpers.js +++ b/.dev/tests/cypress/helpers.js @@ -36,8 +36,9 @@ export function addFormChild( name ) { export function loginToSite() { return goTo( '/wp-login.php', true ) .then( () => { - cy.wait( 250 ); - + // Arbitrary wait to ensure the login form is ready in CI + cy.wait( 1000 ); + // Wait for login form to be ready cy.get( '#user_login' ).type( Cypress.env( 'wpUsername' ) ); cy.get( '#user_pass' ).type( Cypress.env( 'wpPassword' ) ); cy.get( '#wp-submit' ).click(); @@ -68,6 +69,28 @@ export function getWPDataObject() { } ); } +/** + * Wait for WordPress data stores to be fully initialized. + * This prevents race conditions where wp.data exists but stores aren't registered yet. + */ +export function waitForDataStores() { + return cy.window().should( ( win ) => { + // eslint-disable-next-line no-unused-expressions + expect( win.wp ).to.exist; + // eslint-disable-next-line no-unused-expressions + expect( win.wp.data ).to.exist; + // eslint-disable-next-line no-unused-expressions + expect( win.wp.data.select ).to.be.a( 'function' ); + + // Ensure core stores are registered + const coreEditPostSelect = win.wp.data.select( 'core/edit-post' ); + // eslint-disable-next-line no-unused-expressions + expect( coreEditPostSelect ).to.exist; + // eslint-disable-next-line no-unused-expressions + expect( coreEditPostSelect.isFeatureActive ).to.be.a( 'function' ); + } ); +} + /** * Safely obtain the window blocks object or error * when the window object is not available. @@ -82,17 +105,19 @@ export function getWPBlocksObject() { * Disable Gutenberg Tips */ export function disableGutenbergFeatures() { - return getWPDataObject().then( ( data ) => { - // Enable "Top Toolbar" - if ( ! data.select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ) ) { - data.dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); - } + return waitForDataStores().then( () => { + return getWPDataObject().then( ( data ) => { + // Enable "Top Toolbar" + if ( ! data.select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ) ) { + data.dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); + } - if ( data.select( 'core/edit-post' ).isFeatureActive( 'welcomeGuide' ) ) { - data.dispatch( 'core/edit-post' ).toggleFeature( 'welcomeGuide' ); - } + if ( data.select( 'core/edit-post' ).isFeatureActive( 'welcomeGuide' ) ) { + data.dispatch( 'core/edit-post' ).toggleFeature( 'welcomeGuide' ); + } - data.dispatch( 'core/editor' ).disablePublishSidebar(); + data.dispatch( 'core/editor' ).disablePublishSidebar(); + } ); } ); } @@ -126,19 +151,44 @@ export function addBlockToPost( blockName, clearEditor = false ) { * Note: This method is preferred over the old method because * we do not need to test the Core controls around block insertion. */ - getWPDataObject().then( ( data ) => { - getWPBlocksObject().then( ( blocks ) => { - data.dispatch( 'core/block-editor' ).insertBlock( - blocks.createBlock( blockName ) - ); + waitForDataStores().then( () => { + getWPDataObject().then( ( data ) => { + getWPBlocksObject().then( ( blocks ) => { + data.dispatch( 'core/block-editor' ).insertBlock( + blocks.createBlock( blockName ) + ); + } ); } ); } ); // Make sure the block was added to our page cy.get( `[class*="-visual-editor"] [data-type="${ blockName }"]` ).should( 'exist' ); - // Give a short delay for blocks to render. - cy.wait( 250 ); + // Instead of arbitrary wait, wait for the block to be fully rendered + // This means waiting for the block to not have loading states + cy.get( `[data-type="${ blockName }"]` ).should( ( $block ) => { + // The block should be stable (not detaching/reattaching) + expect( $block ).to.have.length.at.least( 1 ); + // The block should have its content loaded (not just a placeholder) + expect( $block.html() ).to.have.length.greaterThan( 50 ); + } ); + + // Also ensure the block has finished its initial render cycle + cy.get( `[data-type="${ blockName }"]` ).should( 'not.have.class', 'is-loading' ); + + // For gallery blocks specifically, wait for upload UI to be ready and stable + if ( blockName.includes( 'gallery' ) ) { + // Wait for the upload interface to be available + cy.get( `[data-type="${ blockName }"]` ).should( ( $block ) => { + const text = $block.text(); + expect( text ).to.satisfy( ( content ) => + content.includes( 'Upload' ) || content.includes( 'Select' ) || content.includes( 'Media Library' ) + ); + } ); + + // Gallery blocks need a small stabilization period to prevent DOM detachment. + cy.wait( 150 ); + } } export function addNewGroupToPost() { @@ -154,8 +204,7 @@ export function addNewGroupToPost() { cy.get( '.block-editor-inserter__search-input,input.block-editor-inserter__search, .components-search-control__input' ).click().type( 'group' ); } - cy.wait( 1000 ); - + // Wait for search results to appear cy.get( '.block-editor-block-types-list__list-item' ).contains( 'Group' ).click(); // Make sure the block was added to our page @@ -172,11 +221,22 @@ export function addNewGroupToPost() { * From inside the WordPress editor open the CoBlocks Gutenberg editor panel */ export function savePage() { - if ( isWP66AtLeast() ) { - cy.get( '.editor-header__settings button.is-primary' ).click(); - } else { - cy.get( '.edit-post-header__settings button.is-primary' ).click(); - } + // Try multiple selectors for cross-version compatibility (WP 6.5-6.8.2) + cy.get( 'body' ).then( ( $body ) => { + if ( $body.find( '.editor-header__settings button.is-primary' ).length > 0 ) { + // WP 6.6+ selector + cy.get( '.editor-header__settings button.is-primary' ).click(); + } else if ( $body.find( '.edit-post-header__settings button.is-primary' ).length > 0 ) { + // WP 6.5 and earlier selector + cy.get( '.edit-post-header__settings button.is-primary' ).click(); + } else if ( $body.find( '.edit-post-header-toolbar__settings button.is-primary' ).length > 0 ) { + // Alternative pattern + cy.get( '.edit-post-header-toolbar__settings button.is-primary' ).click(); + } else { + // Generic fallback + cy.get( 'button.is-primary' ).contains( /save|publish|update/i ).click(); + } + } ); cy.get( '.components-editor-notices__snackbar', { timeout: 120000 } ).should( 'not.be.empty' ); @@ -208,23 +268,42 @@ export function checkForBlockErrors( blockName ) { * View the currently edited page on the front of site */ export function viewPage() { - cy.get( 'button[aria-label="Settings"]' ).then( ( settingsButton ) => { - if ( ! Cypress.$( settingsButton ).hasClass( 'is-pressed' ) && ! Cypress.$( settingsButton ).hasClass( 'is-toggled' ) ) { - cy.get( settingsButton ).click(); + // Open settings panel if not already open + cy.get( 'body' ).then( ( $body ) => { + const settingsButton = $body.find( 'button[aria-label="Settings"]' ); + if ( settingsButton.length > 0 && ! settingsButton.hasClass( 'is-pressed' ) && ! settingsButton.hasClass( 'is-toggled' ) ) { + cy.get( 'button[aria-label="Settings"]' ).click(); } } ); - if ( isWP65AtLeast() ) { - cy.get( '[data-tab-id="edit-post/document"]' ); + // Wait for the settings panel to be visible + cy.get( '.interface-interface-skeleton__sidebar' ).should( 'be.visible' ); - cy.get( '.editor-post-url__panel-dropdown button' ).click(); - } else { - cy.get( 'button[data-label="Post"]' ); + // Try multiple approaches to find the post URL + cy.get( 'body' ).then( ( $body ) => { + if ( $body.find( '[data-tab-id="edit-post/document"]' ).length > 0 ) { + // WP 6.5+ approach + cy.get( '[data-tab-id="edit-post/document"]' ).click(); + // Wait for document tab to become active + cy.get( '[data-tab-id="edit-post/document"]' ).should( 'have.attr', 'aria-selected', 'true' ); + } - cy.get( '.edit-post-post-url__dropdown button' ).click(); - } + // Try different selectors for the URL dropdown + if ( $body.find( '.editor-post-url__panel-dropdown button' ).length > 0 ) { + cy.get( '.editor-post-url__panel-dropdown button' ).click(); + } else if ( $body.find( '.edit-post-post-url__dropdown button' ).length > 0 ) { + cy.get( '.edit-post-post-url__dropdown button' ).click(); + } else if ( $body.find( 'button[data-label="Post"]' ).length > 0 ) { + cy.get( 'button[data-label="Post"]' ).click(); + cy.get( '.edit-post-post-url__dropdown button' ).click(); + } else { + // Fallback: look for any URL-related dropdown + cy.get( '[class*="url"], [class*="permalink"]' ).find( 'button' ).first().click(); + } + } ); - cy.get( '.editor-post-url__link' ).then( ( pageLink ) => { + // Get the link and visit it + cy.get( '.editor-post-url__link, .edit-post-post-url__link' ).then( ( pageLink ) => { const linkAddress = Cypress.$( pageLink ).attr( 'href' ); cy.visit( linkAddress ); } ); @@ -239,14 +318,19 @@ export function editPage() { } /** - * Clear all blocks from the editor + * Clear all blocks from the editor and wait for them to be fully removed */ export function clearBlocks() { - getWPDataObject().then( ( data ) => { - data.dispatch( 'core/block-editor' ).removeBlocks( - data.select( 'core/block-editor' ).getBlocks().map( ( block ) => block.clientId ) - ); + waitForDataStores().then( () => { + getWPDataObject().then( ( data ) => { + data.dispatch( 'core/block-editor' ).removeBlocks( + data.select( 'core/block-editor' ).getBlocks().map( ( block ) => block.clientId ) + ); + } ); } ); + + // Simple wait and verify no blocks remain + cy.get( '.block-editor-block-list__layout' ).should( 'not.contain', '[data-type]' ); } /** @@ -294,34 +378,35 @@ export function setNewBlockStyle( style ) { */ export function selectBlock( name ) { /** - * There are network requests taking place to the REST API to get the blocks and block patterns. - * Sometimes these requests occur and other times they are cached and are not called. - * For that reason is difficult to assert against those requests from core code. - * We introduce an arbitrary wait to avoid a race condition by interacting too quickly. + * Wait for the block to be available in the editor before attempting to select it. + * This replaces the arbitrary wait with a proper assertion. */ - cy.wait( 600 ); + cy.get( `[data-type*="${ name }"], [data-title*="${ name }"]` ).should( 'exist' ); let id = ''; // The block client ID. - cy.window().then( ( win ) => { - // Prefer selector from data-store. - id = win.wp.data.select( 'core/block-editor' ).getBlocks().filter( ( i ) => i?.name === name )[ 0 ]?.clientId; - - // Fallback to selector from DOM. - if ( ! id ) { - cy.get( `[data-type*="${ name }"], [data-title*="${ name }"]` ) - .invoke( 'attr', 'data-block' ) - .then( ( clientId ) => id = clientId ); - } - } ); + waitForDataStores().then( () => { + cy.window().then( ( win ) => { + // Prefer selector from data-store. + id = win.wp.data.select( 'core/block-editor' ).getBlocks().filter( ( i ) => i?.name === name )[ 0 ]?.clientId; + + // Fallback to selector from DOM. + if ( ! id ) { + cy.get( `[data-type*="${ name }"], [data-title*="${ name }"]` ) + .invoke( 'attr', 'data-block' ) + .then( ( clientId ) => id = clientId ); + } + } ); - cy.window().then( ( win ) => { - win.wp.data.dispatch( 'core/block-editor' ).selectBlock( id ); - } ); + cy.window().then( ( win ) => { + win.wp.data.dispatch( 'core/block-editor' ).selectBlock( id ); + } ); - cy.window().then( ( win ) => { - win.wp.data.dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' ); + cy.window().then( ( win ) => { + win.wp.data.dispatch( 'core/edit-post' ).openGeneralSidebar( 'edit-post/block' ); + } ); } ); - cy.wait( 600 ); + // Wait for sidebar to be visible + cy.get( '.interface-interface-skeleton__sidebar' ).should( 'be.visible' ); } /** @@ -388,6 +473,9 @@ export const upload = { cy.get( `[class*="-visual-editor"] [data-type="${ blockName }"]` ).click(); + // Dismiss any popovers that might interfere with clicking + dismissPopovers(); + cy.get( `[class*="-visual-editor"] [data-type="${ blockName }"] img` ).first().click( { force: true } ); cy.get( '.coblocks-gallery-item__button-replace' ).click( { force: true } ); @@ -533,11 +621,28 @@ export function openHeadingToolbarAndSelect( headingLevel ) { * @param {string} checkboxLabelText The checkbox label text. eg: Drop Cap */ export function toggleSettingCheckbox( checkboxLabelText ) { - cy.get( '.components-toggle-control__label' ) + // Atomic approach using should() to avoid DOM detachment + cy.get( 'label' ) .contains( checkboxLabelText ) - .closest( '.components-base-control__field' ) - .find( '.components-form-toggle__input' ) - .click(); + .should( ( $label ) => { + const forAttr = $label.attr( 'for' ); + if ( forAttr ) { + // Find and click the associated input directly in the callback + const input = Cypress.$( `#${ forAttr }` )[ 0 ]; + if ( input ) { + input.click(); + } + } else { + // Fallback: find input within the same control + const control = $label.closest( '.components-base-control, .components-toggle-control' )[ 0 ]; + if ( control ) { + const input = Cypress.$( control ).find( '.components-form-toggle__input, input[type="checkbox"]' )[ 0 ]; + if ( input ) { + input.click(); + } + } + } + } ); } /** @@ -638,3 +743,190 @@ export function getIframeBody( containerClass ) { export const sidebarClass = () => { return isWP66AtLeast() ? '.editor-sidebar__panel' : '.edit-post-sidebar'; }; + +/** + * Click the settings/inspector button with cross-version compatibility + */ +export function openInspectorPanel() { + // Try multiple selectors for cross-version compatibility (WP 6.5-6.8.2) + cy.get( 'body' ).then( ( $body ) => { + if ( $body.find( '.editor-header__settings' ).length > 0 ) { + // WP 6.6+ selector + cy.get( '.editor-header__settings' ).click(); + } else if ( $body.find( '.edit-post-header__settings' ).length > 0 ) { + // WP 6.5 and earlier selector + cy.get( '.edit-post-header__settings' ).click(); + } else if ( $body.find( '.edit-post-header-toolbar__settings' ).length > 0 ) { + // Alternative pattern + cy.get( '.edit-post-header-toolbar__settings' ).click(); + } else { + // Generic fallback using aria-label + cy.get( '[aria-label*="Settings"], [aria-label*="Inspector"]' ).click(); + } + } ); + + // Wait for sidebar to be visible and panels to load + cy.get( '.interface-interface-skeleton__sidebar' ).should( 'be.visible' ); + + // In newer WP versions, we need to click the "Block" tab to see block settings + cy.get( 'body' ).then( ( $body ) => { + const blockTab = $body.find( '[data-tab-id="edit-post/block"]' ); + if ( blockTab.length > 0 ) { + // Click Block tab if it exists and isn't already selected + if ( ! blockTab.attr( 'aria-selected' ) || blockTab.attr( 'aria-selected' ) === 'false' ) { + cy.get( '[data-tab-id="edit-post/block"]' ).click(); + // Wait for panels to appear after clicking Block tab + cy.get( '.components-panel__body-title' ).should( 'have.length.at.least', 1 ); + } + } else { + // For older versions without tabs, wait for panels to be visible + cy.get( '.components-panel__body-title' ).should( 'have.length.at.least', 1 ); + } + } ); +} + +/** + * Dismiss any open popovers that might interfere with test interactions + * This is particularly useful for rich text formatting toolbars that appear unexpectedly + * and prevent clicking on gallery items or other elements. + * + * This function is backward compatible with existing test patterns that expect + * formatting toolbars to appear/disappear in specific sequences. + */ +export function dismissPopovers() { + // Only dismiss popovers if they are currently interfering with interactions + cy.get( 'body' ).then( ( $body ) => { + // Look for specifically problematic popovers that cover content + const interferingPopovers = $body.find( '.components-popover.block-editor-rich-text__inline-format-toolbar.is-positioned:visible' ); + + if ( interferingPopovers.length > 0 ) { + // Use escape key to dismiss - this is the standard WordPress way + cy.get( 'body' ).type( '{esc}' ); + // Brief wait for dismissal + cy.wait( 100 ); + } + } ); + + // Additional check for any other visible popovers that might interfere + cy.get( 'body' ).then( ( $body ) => { + const otherPopovers = $body.find( '.components-popover:visible' ); + // Only dismiss if there are multiple popovers or if they're not expected rich text toolbars + if ( otherPopovers.length > 1 ) { + // Press escape to clear any unexpected popover stack + cy.get( 'body' ).type( '{esc}' ); + cy.wait( 100 ); + } + } ); +} + +/** + * Open a specific panel in the inspector with cross-version compatibility + * + * @param {string} panelName - The name of the panel to open (case-insensitive) + */ +export function openInspectorPanelSection( panelName ) { + // First ensure panels are loaded + cy.get( '.components-panel__body-title' ).should( 'have.length.at.least', 1 ); + + // Find the panel button and expand if needed + cy.get( '.components-panel__body-title button' ) + .contains( new RegExp( panelName, 'i' ) ) + .then( ( $button ) => { + const isExpanded = $button.attr( 'aria-expanded' ) === 'true'; + + if ( ! isExpanded ) { + cy.wrap( $button ).click(); + // Wait for panel to expand by checking aria-expanded again + cy.get( '.components-panel__body-title button' ) + .contains( new RegExp( panelName, 'i' ) ) + .should( 'have.attr', 'aria-expanded', 'true' ); + } + } ); + + // For Link Settings specifically, wait for the SelectControl to render + if ( panelName.toLowerCase().includes( 'link' ) ) { + // Simply check that a select element exists somewhere in the expanded panel area + cy.get( '.components-panel__body select' ).should( 'exist' ); + } +} + +/** + * Wait for and interact with the Link Settings dropdown + * This is purpose-built for the gallery link functionality + * + * @param {string} linkType The type of link to select (e.g., 'custom', 'media', 'attachment', 'none') + */ +export function selectLinkOption( linkType ) { + // Simple approach: find select element that should exist after panel expansion + cy.get( '.components-panel__body select' ) + .should( 'be.visible' ) + .select( linkType ); +} + +/** + * Complete helper function for testing custom link functionality in gallery blocks + * Handles the full flow from block selection to URL input and verification + * + * @param {string} blockName - The block name (e.g., 'coblocks/gallery-offset', 'coblocks/gallery-collage') + * @param {string} customUrl - The URL to set for the custom link + * @param {string} imageSelector - CSS selector for the image element to verify link on + */ +export function setGalleryCustomLink( blockName, customUrl, imageSelector = 'img' ) { + // Use the exact working workflow from debug test + + // Step 1: Block selection + cy.get( `[data-type="${ blockName }"]` ).click(); + + // Step 2: Open inspector and ensure it stays open + cy.get( 'body' ).then( ( $body ) => { + const sidebar = $body.find( '.interface-interface-skeleton__sidebar' ); + if ( ! sidebar.is( ':visible' ) ) { + cy.get( '.editor-header__settings, .edit-post-header__settings' ).click(); + } + } ); + cy.get( '.interface-interface-skeleton__sidebar' ).should( 'be.visible' ); + + // Step 3: Ensure Block tab is active + cy.get( '[data-tab-id="edit-post/block"]' ).then( ( $tab ) => { + if ( $tab.attr( 'aria-selected' ) !== 'true' ) { + cy.get( '[data-tab-id="edit-post/block"]' ).click(); + } + } ); + cy.get( '[data-tab-id="edit-post/block"]' ).should( 'have.attr', 'aria-selected', 'true' ); + + // Step 4: Expand Link Settings panel + cy.get( '.components-panel__body-title button' ).contains( /link/i ).then( ( $button ) => { + const isExpanded = $button.attr( 'aria-expanded' ) === 'true'; + if ( ! isExpanded ) { + cy.wrap( $button ).click(); + } + } ); + cy.get( '.components-panel__body-title button' ).contains( /link/i ).should( 'have.attr', 'aria-expanded', 'true' ); + + // Step 5: Select custom link type + cy.get( '.components-panel__body' ).contains( /link/i ).closest( '.components-panel__body' ).within( () => { + cy.get( 'select' ).select( 'custom' ); + } ); + + // Step 6: Click the image to show URL input + dismissPopovers(); + if ( blockName.includes( 'collage' ) ) { + cy.get( `[data-type="${ blockName }"] .wp-block-coblocks-gallery-collage__item` ).first().click(); + cy.get( `[data-type="${ blockName }"] img` ).first().click( { force: true } ); + } else { + cy.get( `[data-type="${ blockName }"]` ).within( () => { + cy.get( imageSelector ).first().click( { force: true } ); + } ); + } + + // Step 7: Find and use the URL input + cy.get( '.block-editor-url-input input:visible' ).should( 'exist' ).clear().type( customUrl ); + cy.get( 'button[type="submit"]:visible' ).click(); + + // Step 8: Verify + cy.get( `[data-type="${ blockName }"]` ).within( () => { + cy.get( imageSelector ).first() + .should( 'have.attr', 'data-imglink' ) + .and( 'include', customUrl ); + } ); +} diff --git a/.dev/tests/cypress/plugins/index.js b/.dev/tests/cypress/plugins/index.js index e319677c04a..16e69e6b663 100644 --- a/.dev/tests/cypress/plugins/index.js +++ b/.dev/tests/cypress/plugins/index.js @@ -1,3 +1,12 @@ module.exports = ( on ) => { require( 'cypress-log-to-output' ).install( on, ( type, event ) => event.level === 'error' || event.type === 'error' ); + + // Simple task to print diagnostic info from specs into the Node process output + on( 'task', { + log( message ) { + // eslint-disable-next-line no-console + console.log( '[cy.task log]', typeof message === 'object' ? JSON.stringify( message, null, 2 ) : message ); + return null; + }, + } ); }; diff --git a/.dev/tests/cypress/support/commands.js b/.dev/tests/cypress/support/commands.js index 1546f3641aa..3503e5bdf59 100644 --- a/.dev/tests/cypress/support/commands.js +++ b/.dev/tests/cypress/support/commands.js @@ -1,10 +1,12 @@ -import { disableGutenbergFeatures, goTo, loginToSite } from '../helpers'; +import { disableGutenbergFeatures, goTo, loginToSite, waitForDataStores } from '../helpers'; before( function() { loginToSite().then( () => { goTo( '/wp-admin/post-new.php?post_type=post' ).then( () => { - cy.wait( 2000 ); - disableGutenbergFeatures(); + // Wait for WordPress data stores to be ready instead of arbitrary wait + waitForDataStores().then( () => { + disableGutenbergFeatures(); + } ); } ); } ); } ); diff --git a/src/blocks/gallery-collage/edit.js b/src/blocks/gallery-collage/edit.js index 46b2c81bf39..d58e73432fb 100644 --- a/src/blocks/gallery-collage/edit.js +++ b/src/blocks/gallery-collage/edit.js @@ -176,18 +176,36 @@ const GalleryCollageEdit = ( props ) => { { selectedImage === image.index && attributes.linkTo === 'custom' &&
event.preventDefault() }> + onSubmit={ ( event ) => { + event.preventDefault(); + saveCustomLink(); + } }> updateImageAttributes( index, { imgLink } ) } - value={ image.imgLink } + key="url-input" + onChange={ ( imgLink ) => { + // Ensure the value is properly handled + const urlValue = imgLink || ''; + updateImageAttributes( index, { imgLink: urlValue } ); + } } + value={ image.imgLink || '' } + placeholder={ __( 'Enter URL', 'coblocks' ) } + disableSuggestions={ false } + __nextHasNoMarginBottom={ true } + /> +