Skip to content

Commit 5e1d59f

Browse files
committed
Tests: Add E2E tests for all SCF field types
Add comprehensive Playwright E2E tests covering all 24 SCF field types across 7 categories, with a shared test helper plugin for output verification. Field types tested: - Basic: Text, Textarea, Number, Range, Email, URL, Password - Content: WYSIWYG, oEmbed, Image, File, Gallery - Choice: Select, Checkbox, Radio, Button Group, True/False - Relational: Link, Post Object, Page Link, Relationship, Taxonomy, User - Advanced: Google Map, Date Picker, DateTime Picker, Time Picker, Color Picker - Layout: Group, Repeater, Flexible Content, Clone Test infrastructure: - field-helpers.js: Shared utilities for field group creation, Select2 interaction, layout expansion, and common test operations - scf-test-all-field-types.php: Test plugin that outputs field values in identifiable HTML elements for E2E verification - test-image.png: Asset for image/file/gallery field tests
1 parent 53a23f4 commit 5e1d59f

33 files changed

+7810
-0
lines changed

tests/e2e/assets/test-image.png

8.93 KB
Loading

tests/e2e/field-helpers.js

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
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

Comments
 (0)