Bugzilla – Attachment 183206 Details for
Bug 39916
The 'Place booking' modal should have cypress tests
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 39916: Add select2 helpers to cypress
Bug-39916-Add-select2-helpers-to-cypress.patch (text/plain), 23.25 KB, created by
Martin Renvoize (ashimema)
on 2025-06-12 14:51:43 UTC
(
hide
)
Description:
Bug 39916: Add select2 helpers to cypress
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2025-06-12 14:51:43 UTC
Size:
23.25 KB
patch
obsolete
>From 005f5274c208201a13bad04da9722c2b762cac78 Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Date: Fri, 16 May 2025 11:35:38 +0100 >Subject: [PATCH] Bug 39916: Add select2 helpers to cypress > >This adds helper methods for cypress to work with select2 dropdowns: >- selectFromSelect2ByIndex() - select by option index >- selectFromSelect2() - select by text value >- clearSelect2() - clear selection > >These helpers handle the complexities of interacting with Select2 >components which cannot be selected using standard Cypress commands. >--- > t/cypress/support/e2e.js | 3 + > t/cypress/support/select2.js | 536 +++++++++++++++++++++++++++++++++++ > 2 files changed, 539 insertions(+) > create mode 100644 t/cypress/support/select2.js > >diff --git a/t/cypress/support/e2e.js b/t/cypress/support/e2e.js >index e420889c3b5..07f8e58e4fd 100644 >--- a/t/cypress/support/e2e.js >+++ b/t/cypress/support/e2e.js >@@ -24,6 +24,9 @@ > // -- This will overwrite an existing command -- > // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) > >+// Import Select2 helpers >+import "./select2"; >+ > function get_fallback_login_value(param) { > var env_var = param == "username" ? "KOHA_USER" : "KOHA_PASS"; > >diff --git a/t/cypress/support/select2.js b/t/cypress/support/select2.js >new file mode 100644 >index 00000000000..a87e653a554 >--- /dev/null >+++ b/t/cypress/support/select2.js >@@ -0,0 +1,536 @@ >+// Select2Helpers.js - Reusable Cypress functions for Select2 dropdowns >+ >+/** >+ * Helper functions for interacting with Select2 dropdown components in Cypress tests >+ * Supports AJAX-based Select2s, triggering all standard Select2 events, and >+ * multiple selection methods including index and text matching (partial/full) >+ * >+ * CHAINABILITY: >+ * All Select2 helper commands are fully chainable. You can: >+ * - Chain multiple Select2 operations (search, then select) >+ * - Chain Select2 commands with standard Cypress commands >+ * - Split complex interactions into multiple steps for better reliability >+ * >+ * Examples: >+ * cy.get('#mySelect') >+ * .select2({ search: 'foo' }) >+ * .select2({ select: 'FooBar' }); >+ * >+ * cy.get('#mySelect') >+ * .select2({ search: 'bar' }) >+ * .select2({ selectIndex: 0 }) >+ * .should('have.value', 'bar_value'); >+ */ >+ >+/** >+ * Main Select2 interaction command to perform operations on Select2 dropdowns >+ * @param {string|JQuery} [subject] - Optional jQuery element (when used with .select2()) >+ * @param {Object} options - Configuration options for the Select2 operation >+ * @param {string} [options.search] - Search text to enter in the search box >+ * @param {string|Object} [options.select] - Text to select or object with matcher options >+ * @param {number} [options.selectIndex] - Index of the option to select (0-based) >+ * @param {string} [options.selector] - CSS selector to find the select element (when not using subject) >+ * @param {boolean} [options.clearSelection=false] - Whether to clear the current selection first >+ * @param {Function} [options.matcher] - Custom matcher function for complex option selection >+ * @param {number} [options.timeout=10000] - Timeout for AJAX responses in milliseconds >+ * @param {boolean} [options.multiple=false] - Whether the select2 allows multiple selections >+ * @param {number} [options.minSearchLength=3] - Minimum length of search text needed for Ajax select2 >+ * @returns {Cypress.Chainable} - Returns a chainable Cypress object for further commands >+ * >+ * @example >+ * // Basic usage >+ * cy.get('#mySelect').select2({ search: 'foo', select: 'FooBar' }); >+ * >+ * @example >+ * // Chained operations (more reliable, especially for AJAX Select2) >+ * cy.get('#mySelect') >+ * .select2({ search: 'foo' }) >+ * .select2({ select: 'FooBar' }); >+ * >+ * @example >+ * // With custom matcher >+ * cy.get('#mySelect').select2({ >+ * search: 'special', >+ * matcher: (option) => option.text.includes('Special Edition') >+ * }); >+ */ >+Cypress.Commands.add( >+ "select2", >+ { >+ prevSubject: "optional", >+ }, >+ (subject, options) => { >+ // Default configuration >+ const defaults = { >+ search: null, >+ select: null, >+ selectIndex: null, >+ selector: null, >+ clearSelection: false, >+ matcher: null, >+ timeout: 10000, >+ multiple: false, >+ minSearchLength: 3, >+ }; >+ >+ // Merge passed options with defaults >+ const config = { ...defaults, ...options }; >+ >+ // Handle selecting the target Select2 element >+ let $originalSelect; >+ if (subject) { >+ $originalSelect = subject; >+ // Store the element ID as a data attribute for chaining >+ cy.wrap(subject).then($el => { >+ const selectId = $el.attr("id"); >+ if (selectId) { >+ Cypress.$($el).attr("data-select2-helper-id", selectId); >+ } >+ }); >+ } else if (config.selector) { >+ $originalSelect = cy.get(config.selector); >+ } else { >+ throw new Error( >+ "Either provide a subject or a selector to identify the Select2 element" >+ ); >+ } >+ >+ return cy.wrap($originalSelect).then($el => { >+ const selectId = $el.attr("id") || $el.data("select2-helper-id"); >+ >+ if (!selectId) { >+ throw new Error( >+ "Select element must have an ID attribute for the Select2 helper to work correctly" >+ ); >+ } >+ >+ // Handle clearing the selection if requested >+ if (config.clearSelection) { >+ cy.window().then(win => { >+ win.$(`select#${selectId}`).val(null).trigger("change"); >+ }); >+ >+ if ( >+ config.search === null && >+ config.select === null && >+ config.selectIndex === null && >+ config.matcher === null >+ ) { >+ return cy.get(`select#${selectId}`); >+ } >+ } >+ >+ // Find the Select2 container and click to open dropdown >+ cy.get(`select#${selectId}`) >+ .siblings(".select2-container") >+ .first() >+ .find(".select2-selection") >+ .first() >+ .click({ force: true }); >+ >+ // Handle search functionality >+ if (config.search !== null) { >+ // Wait for search field to appear and type search text >+ cy.get(".select2-search--dropdown .select2-search__field") >+ .first() >+ .should("be.visible") >+ .type(config.search, { force: true }); >+ >+ // Check minimum search length requirements >+ if (config.search.length < config.minSearchLength) { >+ cy.log( >+ `Warning: Search text "${config.search}" may be too short for Ajax select2 (minimum typically ${config.minSearchLength} characters)` >+ ); >+ } >+ >+ // Wait for results container to exist >+ cy.get(".select2-results__options", { >+ timeout: config.timeout, >+ }).should("exist"); >+ >+ // Wait for options to be loaded and not in loading state >+ cy.get(".select2-results__option", { >+ timeout: config.timeout, >+ }).should($options => { >+ // Must have at least one option >+ expect($options).to.have.length.at.least(1); >+ >+ const firstOptionText = $options >+ .first() >+ .text() >+ .toLowerCase(); >+ >+ // Should not be in loading states >+ expect(firstOptionText).to.not.include("searching"); >+ expect(firstOptionText).to.not.include("loading"); >+ expect(firstOptionText).to.not.include("please wait"); >+ >+ // Log if no results found, but don't fail here >+ if ( >+ firstOptionText.includes("no results") || >+ firstOptionText.includes("please enter") >+ ) { >+ cy.log( >+ `Warning: No results found for search: "${config.search}"` >+ ); >+ } >+ }); >+ } >+ >+ // Handle selection based on the provided options >+ if ( >+ config.select !== null || >+ config.selectIndex !== null || >+ config.matcher !== null >+ ) { >+ // Wait for valid selectable options to be available and store matching logic >+ cy.get(".select2-results__option") >+ .should($options => { >+ expect($options).to.have.length.at.least(1); >+ >+ const firstOptionText = $options >+ .first() >+ .text() >+ .toLowerCase(); >+ >+ // Should not be in loading or error states for selection >+ expect(firstOptionText).to.not.include("searching"); >+ expect(firstOptionText).to.not.include("loading"); >+ expect(firstOptionText).to.not.include("please wait"); >+ expect(firstOptionText.length).to.be.greaterThan(0); >+ >+ // Should have selectable options (not just error messages) >+ if ( >+ firstOptionText.includes("no results") || >+ firstOptionText.includes("please enter") >+ ) { >+ throw new Error( >+ `No selectable options found for search: "${config.search}"` >+ ); >+ } >+ >+ // Perform selection logic and store the result for later use >+ let optionIndex = -1; >+ >+ // Select by index >+ if (config.selectIndex !== null) { >+ expect(config.selectIndex).to.be.at.least(0); >+ expect(config.selectIndex).to.be.below( >+ $options.length >+ ); >+ optionIndex = config.selectIndex; >+ } >+ // Select by custom matcher function >+ else if ( >+ config.matcher !== null && >+ typeof config.matcher === "function" >+ ) { >+ for (let i = 0; i < $options.length; i++) { >+ const $option = $options.eq(i); >+ const optionContent = { >+ text: $option.text().trim(), >+ html: $option.html(), >+ element: $option[0], >+ }; >+ >+ if (config.matcher(optionContent, i)) { >+ optionIndex = i; >+ break; >+ } >+ } >+ } >+ // Select by text (default) >+ else if (config.select !== null) { >+ if (typeof config.select === "string") { >+ const selectText = config.select; >+ >+ // Try exact match first >+ for (let i = 0; i < $options.length; i++) { >+ if ( >+ Cypress.$($options[i]).text().trim() === >+ selectText >+ ) { >+ optionIndex = i; >+ break; >+ } >+ } >+ >+ // Fall back to partial match if no exact match >+ if (optionIndex === -1) { >+ for (let i = 0; i < $options.length; i++) { >+ if ( >+ Cypress.$($options[i]) >+ .text() >+ .trim() >+ .includes(selectText) >+ ) { >+ optionIndex = i; >+ break; >+ } >+ } >+ } >+ } >+ // Handle object format for advanced matching >+ else if (typeof config.select === "object") { >+ const matchType = >+ config.select.matchType || "partial"; >+ const text = config.select.text; >+ >+ if (!text) { >+ throw new Error( >+ 'When using object format for selection, "text" property is required' >+ ); >+ } >+ >+ let matcher; >+ switch (matchType) { >+ case "exact": >+ matcher = optText => optText === text; >+ break; >+ case "startsWith": >+ matcher = optText => >+ optText.startsWith(text); >+ break; >+ case "endsWith": >+ matcher = optText => >+ optText.endsWith(text); >+ break; >+ case "partial": >+ default: >+ matcher = optText => >+ optText.includes(text); >+ } >+ >+ for (let i = 0; i < $options.length; i++) { >+ const optionText = Cypress.$($options[i]) >+ .text() >+ .trim(); >+ if (matcher(optionText)) { >+ optionIndex = i; >+ break; >+ } >+ } >+ } >+ } >+ >+ // Ensure we found an option to select >+ if (optionIndex === -1) { >+ throw new Error( >+ `Could not find any option matching the selection criteria. Search: "${config.search}", Select: "${config.select}"` >+ ); >+ } >+ >+ // Store the determined index as a data attribute on the first option element >+ // This survives the should() retry cycles and can be read by subsequent commands >+ Cypress.$($options[0]).attr( >+ "data-select2-target-index", >+ optionIndex >+ ); >+ >+ // Return true to satisfy the should() assertion >+ return true; >+ }) >+ .then($options => { >+ // Retrieve the stored index from the data attribute >+ const targetIndex = parseInt( >+ Cypress.$($options[0]).attr( >+ "data-select2-target-index" >+ ), >+ 10 >+ ); >+ >+ // Click the option at the determined index >+ return cy >+ .get(".select2-results__option") >+ .eq(targetIndex) >+ .click({ force: true }); >+ }); >+ >+ // Verify the selection was applied >+ // Wait for the dropdown to close first (indicates selection is processing) >+ cy.get(".select2-dropdown").should("not.exist"); >+ >+ // Wait a moment for Select2 to trigger change events and update the DOM >+ cy.wait(100); >+ >+ // Then verify the selection was applied to the underlying select element >+ cy.get(`select#${selectId}`).should($el => { >+ const value = $el.val(); >+ >+ // For multiple selects, value might be an array >+ if (Array.isArray(value)) { >+ expect(value).to.have.length.at.least(1); >+ expect(value[0]).to.not.be.null; >+ expect(value[0]).to.not.equal(""); >+ } else { >+ expect(value).to.not.be.null; >+ expect(value).to.not.equal(""); >+ } >+ }); >+ } >+ >+ // Return the original select element for chaining >+ return cy.get(`select#${selectId}`); >+ }); >+ } >+); >+ >+/** >+ * Helper to clear a Select2 selection >+ * Can be used as a standalone command or as part of a chain >+ * >+ * @param {string} selector - jQuery-like selector for the original select element >+ * @returns {Cypress.Chainable} - Returns a chainable Cypress object for further commands >+ * >+ * @example >+ * // Standalone usage >+ * cy.clearSelect2('#tagSelect'); >+ * >+ * @example >+ * // As part of a chain >+ * cy.get('#tagSelect') >+ * .select2({ select: 'Mystery' }) >+ * .clearSelect2('#tagSelect') >+ * .select2({ select: 'Fantasy' }); >+ */ >+Cypress.Commands.add("clearSelect2", selector => { >+ return cy.get(selector).select2({ clearSelection: true }); >+}); >+ >+/** >+ * Helper to search in a Select2 dropdown without making a selection >+ * Useful for testing search functionality or as part of a multi-step interaction >+ * >+ * @param {string} selector - jQuery-like selector for the original select element >+ * @param {string} searchText - Text to search for >+ * @returns {Cypress.Chainable} - Returns a chainable Cypress object for further commands >+ * >+ * @example >+ * // Standalone usage to test search functionality >+ * cy.searchSelect2('#authorSelect', 'Gaiman'); >+ * >+ * @example >+ * // Chained with selection - more reliable for AJAX Select2s >+ * cy.get('#publisherSelect') >+ * .searchSelect2('#publisherSelect', "O'Reilly") >+ * .select2({ selectIndex: 0 }); >+ */ >+Cypress.Commands.add("searchSelect2", (selector, searchText) => { >+ return cy.get(selector).select2({ search: searchText }); >+}); >+ >+/** >+ * Helper to select an option in a Select2 dropdown by text >+ * Combines search and select in one command, but can be less reliable for AJAX Select2s >+ * >+ * @param {string} selector - jQuery-like selector for the original select element >+ * @param {string|Object} selectText - Text to select or object with matcher options >+ * @param {string} [searchText=null] - Optional text to search for before selecting >+ * @returns {Cypress.Chainable} - Returns a chainable Cypress object for further commands >+ * >+ * @example >+ * // Basic usage >+ * cy.selectFromSelect2('#authorSelect', 'J.R.R. Tolkien'); >+ * >+ * @example >+ * // With search text >+ * cy.selectFromSelect2('#publisherSelect', 'O\'Reilly Media', 'O\'Reilly'); >+ * >+ * @example >+ * // Using advanced matching options >+ * cy.selectFromSelect2('#bookSelect', >+ * { text: 'The Hobbit', matchType: 'exact' }, >+ * 'Hobbit' >+ * ); >+ * >+ * @example >+ * // Chainable with other Cypress commands >+ * cy.selectFromSelect2('#categorySelect', 'Fiction') >+ * .should('have.value', 'fiction') >+ * .and('be.visible'); >+ */ >+Cypress.Commands.add( >+ "selectFromSelect2", >+ (selector, selectText, searchText = null) => { >+ return cy.get(selector).select2({ >+ search: searchText, >+ select: selectText, >+ }); >+ } >+); >+ >+/** >+ * Helper to select an option in a Select2 dropdown by index >+ * Useful when the exact text is unknown or when needing to select a specific item by position >+ * >+ * @param {string} selector - jQuery-like selector for the original select element >+ * @param {number} index - Index of the option to select (0-based) >+ * @param {string} [searchText=null] - Optional text to search for before selecting >+ * @returns {Cypress.Chainable} - Returns a chainable Cypress object for further commands >+ * >+ * @example >+ * // Select the first item in dropdown >+ * cy.selectFromSelect2ByIndex('#categorySelect', 0); >+ * >+ * @example >+ * // Search first, then select by index >+ * cy.selectFromSelect2ByIndex('#bookSelect', 2, 'Fiction'); >+ * >+ * @example >+ * // Chain with assertions >+ * cy.selectFromSelect2ByIndex('#authorSelect', 0) >+ * .should('not.have.value', '') >+ * .and('be.visible'); >+ */ >+Cypress.Commands.add( >+ "selectFromSelect2ByIndex", >+ (selector, index, searchText = null) => { >+ return cy.get(selector).select2({ >+ search: searchText, >+ selectIndex: index, >+ }); >+ } >+); >+ >+/** >+ * Helper to select an option in a Select2 dropdown using a custom matcher function >+ * Most flexible option for complex Select2 structures with nested elements or specific attributes >+ * >+ * @param {string} selector - jQuery-like selector for the original select element >+ * @param {Function} matcherFn - Custom function to match against option content >+ * @param {string} [searchText=null] - Optional text to search for before selecting >+ * @returns {Cypress.Chainable} - Returns a chainable Cypress object for further commands >+ * >+ * @example >+ * // Select option with specific data attribute >+ * cy.selectFromSelect2WithMatcher('#bookSelect', >+ * (option) => option.element.hasAttribute('data-special') && >+ * option.text.includes('Special Edition'), >+ * 'Lord of the Rings' >+ * ); >+ * >+ * @example >+ * // Select option that contains both title and author >+ * cy.selectFromSelect2WithMatcher('#bookSelect', >+ * (option) => option.text.includes('Tolkien') && option.text.includes('Hobbit'), >+ * 'Tolkien' >+ * ); >+ * >+ * @example >+ * // Chain with other commands >+ * cy.selectFromSelect2WithMatcher('#publisherSelect', >+ * (option) => option.html.includes('<em>Premium</em>'), >+ * 'Premium' >+ * ).then(() => { >+ * cy.get('#premium-options').should('be.visible'); >+ * }); >+ */ >+Cypress.Commands.add( >+ "selectFromSelect2WithMatcher", >+ (selector, matcherFn, searchText = null) => { >+ return cy.get(selector).select2({ >+ search: searchText, >+ matcher: matcherFn, >+ }); >+ } >+); >-- >2.49.0
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 39916
:
182515
|
182516
|
182545
|
182546
|
182635
|
182636
|
182637
|
182771
|
182772
|
182773
| 183206 |
183207
|
183208
|
183209
|
183210