|
| 1 | +/** |
| 2 | + * Shared helper functions for field type E2E tests. |
| 3 | + * |
| 4 | + * Provides common utilities for creating field groups, managing fields, |
| 5 | + * and cleaning up test data across all field type tests. |
| 6 | + */ |
| 7 | + |
| 8 | +const PLUGIN_SLUG = 'secure-custom-fields'; |
| 9 | + |
| 10 | +/** |
| 11 | + * Delete all field groups and empty trash. |
| 12 | + * |
| 13 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 14 | + * @param {Object} admin Admin utilities. |
| 15 | + */ |
| 16 | +async function deleteFieldGroups( page, admin ) { |
| 17 | + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); |
| 18 | + |
| 19 | + // Check if there are any field groups in the table (not just the checkbox) |
| 20 | + const fieldGroupRows = page.locator( |
| 21 | + 'table.wp-list-table tbody tr:not(.no-items)' |
| 22 | + ); |
| 23 | + const rowCount = await fieldGroupRows.count(); |
| 24 | + |
| 25 | + if ( rowCount > 0 ) { |
| 26 | + const allFieldGroupsCheckbox = page.locator( 'input#cb-select-all-1' ); |
| 27 | + await allFieldGroupsCheckbox.check(); |
| 28 | + await page.selectOption( '#bulk-action-selector-bottom', 'trash' ); |
| 29 | + await page.click( '#doaction2' ); |
| 30 | + |
| 31 | + // Wait for deletion |
| 32 | + const deleteMessage = page.locator( '.updated.notice' ); |
| 33 | + await deleteMessage.waitFor( { state: 'visible', timeout: 5000 } ); |
| 34 | + |
| 35 | + await emptyTrash( page, admin ); |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * Empty the field groups trash. |
| 41 | + * |
| 42 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 43 | + * @param {Object} admin Admin utilities. |
| 44 | + */ |
| 45 | +async function emptyTrash( page, admin ) { |
| 46 | + await admin.visitAdminPage( |
| 47 | + 'edit.php', |
| 48 | + 'post_status=trash&post_type=acf-field-group' |
| 49 | + ); |
| 50 | + const emptyTrashButton = page.locator( |
| 51 | + '.tablenav.bottom input[name="delete_all"][value="Empty Trash"]' |
| 52 | + ); |
| 53 | + |
| 54 | + if ( await emptyTrashButton.isVisible() ) { |
| 55 | + await emptyTrashButton.click(); |
| 56 | + // Wait for either the success notice or page to reload |
| 57 | + // The notice selector may vary between WordPress versions |
| 58 | + const successNotice = page.locator( '.notice.updated, .updated.notice' ); |
| 59 | + await successNotice.first().waitFor( { state: 'visible', timeout: 5000 } ).catch( () => { |
| 60 | + // If no notice appears, the page might have redirected, which is also valid |
| 61 | + } ); |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * Create a new field group with a single field. |
| 67 | + * |
| 68 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 69 | + * @param {Object} admin Admin utilities. |
| 70 | + * @param {Object} options Field options. |
| 71 | + * @param {string} options.groupTitle Field group title. |
| 72 | + * @param {string} options.fieldLabel Field label. |
| 73 | + * @param {string} options.fieldType Field type (e.g., 'text', 'image'). |
| 74 | + * @param {Function} [options.configure] Optional callback for field configuration. |
| 75 | + */ |
| 76 | +async function createFieldGroup( page, admin, options ) { |
| 77 | + const { groupTitle, fieldLabel, fieldType, configure } = options; |
| 78 | + |
| 79 | + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); |
| 80 | + const addNewButton = page.locator( 'a.acf-btn:has-text("Add New")' ); |
| 81 | + await addNewButton.click(); |
| 82 | + |
| 83 | + // Fill field group title |
| 84 | + await page.waitForSelector( '#title' ); |
| 85 | + await page.fill( '#title', groupTitle ); |
| 86 | + |
| 87 | + // Set field label |
| 88 | + const fieldLabelInput = page.locator( |
| 89 | + 'input[id^="acf_fields-field_"][id$="-label"]' |
| 90 | + ); |
| 91 | + await fieldLabelInput.fill( fieldLabel ); |
| 92 | + |
| 93 | + // Select field type |
| 94 | + const fieldTypeSelect = page.locator( |
| 95 | + 'select[id^="acf_fields-field_"][id$="-type"]' |
| 96 | + ); |
| 97 | + await fieldTypeSelect.selectOption( fieldType ); |
| 98 | + |
| 99 | + // Run custom configuration if provided |
| 100 | + if ( configure ) { |
| 101 | + await configure( page ); |
| 102 | + } |
| 103 | + |
| 104 | + // Publish |
| 105 | + const publishButton = page.locator( |
| 106 | + 'button.acf-btn.acf-publish[type="submit"]' |
| 107 | + ); |
| 108 | + await publishButton.click(); |
| 109 | + |
| 110 | + // Verify success |
| 111 | + const successNotice = page.locator( '.updated.notice' ); |
| 112 | + await successNotice.waitFor( { state: 'visible', timeout: 5000 } ); |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * Wait for meta boxes to load and expand if collapsed. |
| 117 | + * |
| 118 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 119 | + */ |
| 120 | +async function waitForMetaBoxes( page ) { |
| 121 | + // Wait for at least one ACF postbox to be attached |
| 122 | + // Using locator instead of waitForSelector to handle multiple matches |
| 123 | + const postboxes = page.locator( '.acf-postbox' ); |
| 124 | + await postboxes.first().waitFor( { state: 'attached' } ); |
| 125 | + |
| 126 | + const metaBoxPanel = page.getByRole( 'button', { name: 'Meta Boxes' } ); |
| 127 | + if ( |
| 128 | + ( await metaBoxPanel.count() ) > 0 && |
| 129 | + ( await metaBoxPanel.getAttribute( 'aria-expanded' ) ) === 'false' |
| 130 | + ) { |
| 131 | + await metaBoxPanel.focus(); |
| 132 | + await metaBoxPanel.press( 'Enter' ); |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * Upload an image to the media library via the media modal. |
| 138 | + * |
| 139 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 140 | + * @param {string} imagePath Path to the image file. |
| 141 | + */ |
| 142 | +async function uploadImageViaModal( page, imagePath ) { |
| 143 | + // Wait for media modal to open |
| 144 | + await page.waitForSelector( '.media-modal', { state: 'visible' } ); |
| 145 | + |
| 146 | + // Click "Upload files" tab if not already there |
| 147 | + const uploadTab = page.locator( '.media-modal #menu-item-upload' ); |
| 148 | + if ( await uploadTab.isVisible() ) { |
| 149 | + await uploadTab.click(); |
| 150 | + } |
| 151 | + |
| 152 | + // Upload the file |
| 153 | + const fileInput = page.locator( '.media-modal input[type="file"]' ); |
| 154 | + await fileInput.setInputFiles( imagePath ); |
| 155 | + |
| 156 | + // Wait for upload to complete |
| 157 | + await page.waitForSelector( '.media-modal .attachment.selected', { |
| 158 | + state: 'visible', |
| 159 | + timeout: 15000, |
| 160 | + } ); |
| 161 | + |
| 162 | + // Click "Select" button |
| 163 | + const selectButton = page.locator( |
| 164 | + '.media-modal .media-toolbar-primary .media-button-select' |
| 165 | + ); |
| 166 | + await selectButton.click(); |
| 167 | + |
| 168 | + // Wait for modal to close |
| 169 | + await page.waitForSelector( '.media-modal', { state: 'hidden' } ); |
| 170 | +} |
| 171 | + |
| 172 | +/** |
| 173 | + * Add choices to a select/checkbox/radio field. |
| 174 | + * |
| 175 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 176 | + * @param {string[]} choices Array of choices in "value : label" format. |
| 177 | + */ |
| 178 | +async function addFieldChoices( page, choices ) { |
| 179 | + const choicesTextarea = page.locator( |
| 180 | + 'textarea[id^="acf_fields-field_"][id$="-choices"]' |
| 181 | + ); |
| 182 | + await choicesTextarea.fill( choices.join( '\n' ) ); |
| 183 | +} |
| 184 | + |
| 185 | +/** |
| 186 | + * Add a subfield to a repeater, group, or flexible content field. |
| 187 | + * |
| 188 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 189 | + * @param {Object} options Subfield options. |
| 190 | + * @param {string} options.label Subfield label. |
| 191 | + * @param {string} options.type Subfield type. |
| 192 | + * @param {boolean} [options.isFirst] Whether this is the first subfield. |
| 193 | + */ |
| 194 | +async function addSubfield( page, options ) { |
| 195 | + const { label, type, isFirst = false } = options; |
| 196 | + |
| 197 | + if ( isFirst ) { |
| 198 | + // Click "Add Field" button for first subfield |
| 199 | + const addFirstButton = page.locator( |
| 200 | + '.acf-field-setting-sub_fields a.add-first-field' |
| 201 | + ); |
| 202 | + await addFirstButton.click(); |
| 203 | + } else { |
| 204 | + // Click "Add Field" button for additional subfields |
| 205 | + const addFieldButton = page.locator( |
| 206 | + '.acf-field-setting-sub_fields .acf-is-subfields a.add-field.acf-btn-secondary' |
| 207 | + ); |
| 208 | + await addFieldButton.click(); |
| 209 | + } |
| 210 | + |
| 211 | + // Wait for subfield to appear and set properties |
| 212 | + const subFieldLabel = page |
| 213 | + .locator( '.acf-field-object input.field-label' ) |
| 214 | + .last(); |
| 215 | + await subFieldLabel.waitFor(); |
| 216 | + await subFieldLabel.fill( label ); |
| 217 | + |
| 218 | + // Set type if not text (default) |
| 219 | + if ( type !== 'text' ) { |
| 220 | + const subFieldType = page |
| 221 | + .locator( '.acf-field-object' ) |
| 222 | + .last() |
| 223 | + .locator( 'select.field-type' ); |
| 224 | + await subFieldType.selectOption( type ); |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * Add a layout to a flexible content field. |
| 230 | + * |
| 231 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 232 | + * @param {Object} options Layout options. |
| 233 | + * @param {string} options.label Layout label. |
| 234 | + */ |
| 235 | +async function addFlexibleContentLayout( page, options ) { |
| 236 | + const { label } = options; |
| 237 | + |
| 238 | + // Click "Add Layout" button |
| 239 | + const addLayoutButton = page.locator( |
| 240 | + '.acf-field-setting-fc_layouts a.add-layout' |
| 241 | + ); |
| 242 | + await addLayoutButton.click(); |
| 243 | + |
| 244 | + // Fill layout label |
| 245 | + const layoutLabel = page.locator( |
| 246 | + '.acf-field-setting-fc_layouts .acf-fc-layout-label input' |
| 247 | + ); |
| 248 | + await layoutLabel.last().fill( label ); |
| 249 | +} |
| 250 | + |
| 251 | +/** |
| 252 | + * Toggle an SCF switch/checkbox setting. |
| 253 | + * |
| 254 | + * SCF uses custom toggle switches (`.acf-switch`) that overlay the actual checkbox. |
| 255 | + * This helper properly handles clicking either the switch or falling back to force-click. |
| 256 | + * It also handles settings that may be on different tabs (General, Validation, Presentation, Conditional Logic). |
| 257 | + * |
| 258 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 259 | + * @param {string} selector Selector for the setting container (e.g., '.acf-field-setting-multiple'). |
| 260 | + * @param {boolean} checked Whether to check (true) or uncheck (false). |
| 261 | + */ |
| 262 | +async function toggleFieldSetting( page, selector, checked = true ) { |
| 263 | + const settingContainer = page.locator( selector ); |
| 264 | + |
| 265 | + // First, wait for the setting container to be attached (it might be loading after field type change) |
| 266 | + await settingContainer.waitFor( { state: 'attached', timeout: 5000 } ).catch( () => {} ); |
| 267 | + |
| 268 | + // Check if the setting container is visible (not just attached) |
| 269 | + let isVisible = await settingContainer.isVisible().catch( () => false ); |
| 270 | + |
| 271 | + // If not visible, try clicking through different tabs to find it |
| 272 | + if ( ! isVisible ) { |
| 273 | + const tabs = [ |
| 274 | + 'General', |
| 275 | + 'Validation', |
| 276 | + 'Presentation', |
| 277 | + 'Conditional Logic', |
| 278 | + 'Advanced', |
| 279 | + ]; |
| 280 | + for ( const tabName of tabs ) { |
| 281 | + // Use more specific selector that matches the exact tab link text |
| 282 | + const tab = page.locator( |
| 283 | + `.acf-field-object .acf-tab-wrap a.acf-tab-button` |
| 284 | + ).filter( { hasText: tabName } ); |
| 285 | + if ( ( await tab.count() ) > 0 && ( await tab.isVisible() ) ) { |
| 286 | + await tab.click(); |
| 287 | + await page.waitForTimeout( 150 ); |
| 288 | + |
| 289 | + // Check if the setting is now visible |
| 290 | + isVisible = await settingContainer.isVisible().catch( () => false ); |
| 291 | + if ( isVisible ) { |
| 292 | + break; |
| 293 | + } |
| 294 | + } |
| 295 | + } |
| 296 | + } |
| 297 | + |
| 298 | + // Wait for the setting container to be visible |
| 299 | + await settingContainer.waitFor( { state: 'visible', timeout: 5000 } ); |
| 300 | + |
| 301 | + // Scroll into view using evaluate (works even when element is hidden by overflow) |
| 302 | + await settingContainer.evaluate( ( el ) => { |
| 303 | + el.scrollIntoView( { block: 'center', behavior: 'instant' } ); |
| 304 | + } ); |
| 305 | + await page.waitForTimeout( 100 ); |
| 306 | + |
| 307 | + // First try to click the ACF switch if it exists (even if not visible due to overlay) |
| 308 | + const acfSwitch = settingContainer.locator( '.acf-switch' ); |
| 309 | + if ( ( await acfSwitch.count() ) > 0 ) { |
| 310 | + const isCurrentlyOn = |
| 311 | + ( await acfSwitch.getAttribute( 'class' ) )?.includes( '-on' ) ?? |
| 312 | + false; |
| 313 | + if ( isCurrentlyOn !== checked ) { |
| 314 | + await acfSwitch.click( { force: true } ); |
| 315 | + } |
| 316 | + return; |
| 317 | + } |
| 318 | + |
| 319 | + // Fallback to checkbox with force click (for non-switch toggles) |
| 320 | + const checkbox = settingContainer.locator( 'input[type="checkbox"]' ); |
| 321 | + if ( ( await checkbox.count() ) > 0 ) { |
| 322 | + const isChecked = await checkbox.isChecked(); |
| 323 | + if ( isChecked !== checked ) { |
| 324 | + await checkbox.click( { force: true } ); |
| 325 | + } |
| 326 | + } |
| 327 | +} |
| 328 | + |
| 329 | +/** |
| 330 | + * Scroll an element into view to avoid header interception. |
| 331 | + * |
| 332 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 333 | + * @param {import('@playwright/test').Locator} element Element locator. |
| 334 | + */ |
| 335 | +async function scrollIntoViewWithOffset( page, element ) { |
| 336 | + await element.evaluate( ( el ) => { |
| 337 | + el.scrollIntoView( { block: 'center', behavior: 'instant' } ); |
| 338 | + } ); |
| 339 | + // Small delay to ensure scroll completes |
| 340 | + await page.waitForTimeout( 100 ); |
| 341 | +} |
| 342 | + |
| 343 | +/** |
| 344 | + * Select an option in a Select2 AJAX dropdown. |
| 345 | + * |
| 346 | + * @param {import('@playwright/test').Page} page Playwright page object. |
| 347 | + * @param {import('@playwright/test').Locator} containerSelector Selector for the Select2 container parent. |
| 348 | + * @param {string} searchText Text to search for. |
| 349 | + * @param {string} optionText Text of the option to select. |
| 350 | + */ |
| 351 | +async function selectSelect2Option( page, containerSelector, searchText, optionText ) { |
| 352 | + const container = page.locator( containerSelector ); |
| 353 | + const select2 = container.locator( '.select2-container' ); |
| 354 | + await select2.click(); |
| 355 | + await page.waitForTimeout( 200 ); |
| 356 | + |
| 357 | + const searchField = page.locator( '.select2-search__field' ).first(); |
| 358 | + await searchField.fill( searchText ); |
| 359 | + await page.waitForTimeout( 500 ); // Wait for AJAX results |
| 360 | + |
| 361 | + const option = page |
| 362 | + .locator( `.select2-results__option:has-text("${ optionText }")` ) |
| 363 | + .first(); |
| 364 | + await option.waitFor( { timeout: 5000 } ); |
| 365 | + await option.click(); |
| 366 | +} |
| 367 | + |
| 368 | +/** |
| 369 | + * Expand a collapsed Flexible Content layout section. |
| 370 | + * |
| 371 | + * @param {import('@playwright/test').Locator} layout The layout locator. |
| 372 | + */ |
| 373 | +async function expandFCLayout( layout ) { |
| 374 | + const layoutSettings = layout.locator( '.acf-field-layout-settings' ); |
| 375 | + if ( ! ( await layoutSettings.isVisible() ) ) { |
| 376 | + await layout.locator( '.acf-field-settings-fc_head' ).click(); |
| 377 | + await layoutSettings.waitFor( { state: 'visible', timeout: 5000 } ); |
| 378 | + } |
| 379 | +} |
| 380 | + |
| 381 | +module.exports = { |
| 382 | + PLUGIN_SLUG, |
| 383 | + deleteFieldGroups, |
| 384 | + emptyTrash, |
| 385 | + createFieldGroup, |
| 386 | + waitForMetaBoxes, |
| 387 | + uploadImageViaModal, |
| 388 | + addFieldChoices, |
| 389 | + addSubfield, |
| 390 | + addFlexibleContentLayout, |
| 391 | + toggleFieldSetting, |
| 392 | + scrollIntoViewWithOffset, |
| 393 | + selectSelect2Option, |
| 394 | + expandFCLayout, |
| 395 | +}; |
0 commit comments