diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..2d5c2825 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,14 @@ +node_modules/ +vendor/ +wordpress/ +assets/build/ +coverage/ +.nyc_output/ +.php-coverage/ +artifacts/ +test-results/ +playwright-report/ +blob-report/ +release/ +.claude/ +**/.claude/ diff --git a/jest.config.js b/jest.config.js index db66a9d9..4cdbba33 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ module.exports = { ...require( '@wordpress/scripts/config/jest-unit.config' ), testMatch: [ '**/tests/js/**/*.test.js', '**/tests/js/**/*.test.jsx' ], + testPathIgnorePatterns: [ '/node_modules/', '/.claude/' ], setupFilesAfterEnv: [ '/tests/js/setup-tests.js' ], testEnvironment: 'jsdom', moduleNameMapper: { diff --git a/tests/e2e/abilities-fields.spec.ts b/tests/e2e/abilities-fields.spec.ts index 3d83e211..6be1a177 100644 --- a/tests/e2e/abilities-fields.spec.ts +++ b/tests/e2e/abilities-fields.spec.ts @@ -5,6 +5,7 @@ * Fields require a parent field group, so we create/cleanup field groups in beforeAll/afterAll. */ const { test, expect } = require( './fixtures' ); +const { purgeScfInternalPosts } = require( './field-helpers' ); const PLUGIN_SLUG = 'secure-custom-fields'; const ABILITIES_BASE = '/wp-abilities/v1/abilities'; @@ -195,6 +196,12 @@ test.describe( 'Field Abilities', () => { 'Abilities API not available in this WordPress version' ); + // Purge any field groups/fields left behind by other specs or + // aborted runs: list-fields validates every stored field against + // its output schema, so a single stray field fails the listing. + await requestUtils.activatePlugin( 'scf-test-utilities' ); + await purgeScfInternalPosts( requestUtils ); + // Create parent field group await fieldGroupApi.cleanup( requestUtils, TEST_FIELD_GROUP.key ); const fieldGroup = await fieldGroupApi.create( requestUtils ); @@ -230,12 +237,14 @@ test.describe( 'Field Abilities', () => { } ); test( 'should support filter by type', async ( { requestUtils } ) => { - const result = await fieldApi.list( requestUtils, { type: 'text' } ); + const result = await fieldApi.list( requestUtils, { + type: 'text', + } ); expect( Array.isArray( result ) ).toBe( true ); - expect( - result.every( ( item ) => item.type === 'text' ) - ).toBe( true ); + expect( result.every( ( item ) => item.type === 'text' ) ).toBe( + true + ); } ); test( 'should support filter by parent', async ( { requestUtils } ) => { @@ -299,7 +308,10 @@ test.describe( 'Field Abilities', () => { } ); test( 'should export a field as JSON', async ( { requestUtils } ) => { - const result = await fieldApi.export( requestUtils, TEST_FIELD.key ); + const result = await fieldApi.export( + requestUtils, + TEST_FIELD.key + ); expect( result ).toHaveProperty( 'key', TEST_FIELD.key ); expect( result ).toHaveProperty( 'label', TEST_FIELD.label ); @@ -415,11 +427,16 @@ test.describe( 'Field Abilities', () => { } ); test( 'should delete an existing field', async ( { requestUtils } ) => { - const result = await fieldApi.delete( requestUtils, TEST_FIELD.key ); + const result = await fieldApi.delete( + requestUtils, + TEST_FIELD.key + ); expect( result ).toBe( true ); // Verify it's actually deleted - await expectNotFound( fieldApi.get( requestUtils, TEST_FIELD.key ) ); + await expectNotFound( + fieldApi.get( requestUtils, TEST_FIELD.key ) + ); } ); test( 'should return error for non-existent field', async ( { diff --git a/tests/e2e/field-group-duplication.spec.ts b/tests/e2e/field-group-duplication.spec.ts new file mode 100644 index 00000000..74452d9d --- /dev/null +++ b/tests/e2e/field-group-duplication.spec.ts @@ -0,0 +1,269 @@ +/** + * E2E tests for field group duplication. + * + * Covers duplicating a field group (with a mix of field types and field + * settings) from the field group list "Duplicate" row action, verifying + * that the duplicate copies the title, fields, and field settings with + * new unique field keys, and that both field groups render independently + * on a post edit screen. + */ +const { test, expect } = require( './fixtures' ); +const { + PLUGIN_SLUG, + addFieldChoices, + purgeScfInternalPosts, +} = require( './field-helpers' ); + +const UTILITIES_PLUGIN_SLUG = 'scf-test-utilities'; + +const SOURCE_GROUP_TITLE = 'Duplication Source'; +const DUPLICATE_GROUP_TITLE = 'Duplication Source (copy)'; +const TEXT_FIELD_LABEL = 'Dup Text Field'; +const TEXT_FIELD_NAME = 'dup_text_field'; +const SELECT_FIELD_LABEL = 'Dup Select Field'; +const SELECT_CHOICES = [ 'red : Red', 'blue : Blue', 'green : Green' ]; + +const DEFAULT_TIMEOUT = 5000; + +/** + * Collect the data-key attributes of all field objects in the currently + * open field group editor. + * + * @param {import('@playwright/test').Page} page Playwright page object. + * @return {Promise} Array of field keys. + */ +async function getFieldKeys( page ) { + const fieldObjects = page.locator( '.acf-field-list > .acf-field-object' ); + const count = await fieldObjects.count(); + const keys = []; + for ( let i = 0; i < count; i++ ) { + keys.push( await fieldObjects.nth( i ).getAttribute( 'data-key' ) ); + } + return keys; +} + +test.describe( 'Field Group Duplication', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( PLUGIN_SLUG ); + await requestUtils.activatePlugin( UTILITIES_PLUGIN_SLUG ); + await purgeScfInternalPosts( requestUtils, [ + 'acf-field-group', + 'acf-field', + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await purgeScfInternalPosts( requestUtils, [ + 'acf-field-group', + 'acf-field', + ] ); + await requestUtils.deleteAllPosts(); + await requestUtils.deactivatePlugin( UTILITIES_PLUGIN_SLUG ); + await requestUtils.deactivatePlugin( PLUGIN_SLUG ); + } ); + + test( 'should duplicate a field group copying fields and settings with new field keys', async ( { + page, + admin, + requestUtils, + } ) => { + // The default location rule (Post Type == Post) is kept so both + // groups can later render on a post edit screen. + const originalKeys = + await test.step( 'Create a field group with a text field and a select field with choices', async () => { + await admin.visitAdminPage( + 'edit.php', + 'post_type=acf-field-group' + ); + await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); + + await page.waitForSelector( '#title' ); + await page.fill( '#title', SOURCE_GROUP_TITLE ); + + // First (auto-added) field: text. + const firstFieldLabel = page.locator( + 'input[id^="acf_fields-field_"][id$="-label"]' + ); + await firstFieldLabel.fill( TEXT_FIELD_LABEL ); + const firstFieldType = page.locator( + 'select[id^="acf_fields-field_"][id$="-type"]' + ); + await firstFieldType.selectOption( 'text' ); + + // Second field: select with choices. + await page + .locator( '.acf-field-list-wrap > .acf-tfoot a.add-field' ) + .click(); + const newFieldObject = page + .locator( '.acf-field-list > .acf-field-object' ) + .last(); + const newFieldLabel = newFieldObject.locator( + 'input[id^="acf_fields-field_"][id$="-label"]' + ); + await expect( newFieldLabel ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await newFieldLabel.fill( SELECT_FIELD_LABEL ); + await newFieldObject + .locator( 'select[id^="acf_fields-field_"][id$="-type"]' ) + .selectOption( 'select' ); + + // The select field is the only one with a choices setting. + await addFieldChoices( page, SELECT_CHOICES ); + + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + const publishedNotice = page.locator( '.updated.notice' ); + await expect( publishedNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( publishedNotice ).toContainText( + 'Field group published' + ); + + // Collect the original field keys from the editor. + const keys = await getFieldKeys( page ); + expect( keys ).toHaveLength( 2 ); + for ( const key of keys ) { + expect( key ).toMatch( /^field_/ ); + } + return keys; + } ); + + await test.step( 'Duplicate the field group from the list row action', async () => { + await admin.visitAdminPage( + 'edit.php', + 'post_type=acf-field-group' + ); + const sourceRow = page.locator( '#the-list tr', { + hasText: SOURCE_GROUP_TITLE, + } ); + await expect( sourceRow ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await sourceRow.hover(); + await sourceRow + .locator( '.row-actions span.acfduplicate a' ) + .click(); + + // The list reloads with a success notice linking to the duplicate. + const duplicatedNotice = page.locator( '.acf-admin-notice' ); + await expect( duplicatedNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( duplicatedNotice ).toContainText( + 'field group duplicated' + ); + + // Both groups now appear in the list. + await expect( + page.locator( + `#the-list a.row-title:has-text("${ DUPLICATE_GROUP_TITLE }")` + ) + ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); + } ); + + await test.step( 'Open the duplicate and verify title, fields, settings, and new field keys', async () => { + await page + .locator( + `#the-list a.row-title:has-text("${ DUPLICATE_GROUP_TITLE }")` + ) + .click(); + await page.waitForSelector( '#title' ); + await expect( page.locator( '#title' ) ).toHaveValue( + DUPLICATE_GROUP_TITLE + ); + + // Both fields were copied with their labels and types. + const duplicateFieldObjects = page.locator( + '.acf-field-list > .acf-field-object' + ); + await expect( duplicateFieldObjects ).toHaveCount( 2 ); + await expect( + duplicateFieldObjects.nth( 0 ).locator( '.li-field-label' ) + ).toContainText( TEXT_FIELD_LABEL ); + await expect( + duplicateFieldObjects.nth( 1 ).locator( '.li-field-label' ) + ).toContainText( SELECT_FIELD_LABEL ); + expect( + await duplicateFieldObjects.nth( 0 ).getAttribute( 'data-type' ) + ).toBe( 'text' ); + expect( + await duplicateFieldObjects.nth( 1 ).getAttribute( 'data-type' ) + ).toBe( 'select' ); + + // The select field's choices were copied. Field settings are + // rendered lazily, so expand the select field row first. + await duplicateFieldObjects + .nth( 1 ) + .locator( 'a.edit-field, button.edit-field' ) + .first() + .click(); + // Note: saved fields use their numeric ID in input ids, so target + // the setting class instead of the id pattern used for new fields. + const duplicateChoices = duplicateFieldObjects + .nth( 1 ) + .locator( '.acf-field-setting-choices textarea' ); + await expect( duplicateChoices ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + expect( await duplicateChoices.inputValue() ).toBe( + SELECT_CHOICES.join( '\n' ) + ); + + // All duplicated field keys are valid and differ from the + // originals. + const duplicateKeys = await getFieldKeys( page ); + expect( duplicateKeys ).toHaveLength( 2 ); + for ( const key of duplicateKeys ) { + expect( key ).toMatch( /^field_/ ); + expect( originalKeys ).not.toContain( key ); + } + } ); + + await test.step( 'Verify the original group is untouched', async () => { + await admin.visitAdminPage( + 'edit.php', + 'post_type=acf-field-group' + ); + await page + .locator( + `#the-list a.row-title:has-text("${ SOURCE_GROUP_TITLE }")` + ) + .first() + .click(); + await page.waitForSelector( '#title' ); + await expect( page.locator( '#title' ) ).toHaveValue( + SOURCE_GROUP_TITLE + ); + const sourceKeysAfter = await getFieldKeys( page ); + expect( sourceKeysAfter ).toEqual( originalKeys ); + } ); + + await test.step( 'Verify both groups render independent metaboxes on a post edit screen', async () => { + const post = await requestUtils.createPost( { + title: 'Duplication Check Post', + status: 'draft', + } ); + await admin.editPost( post.id ); + + // Wait for SCF metaboxes to be attached in the editor. + await page + .locator( '.acf-postbox' ) + .first() + .waitFor( { state: 'attached' } ); + + // One metabox per field group, each with its own copy of the + // fields. + const duplicatePostbox = page.locator( + `.acf-postbox:has-text("${ DUPLICATE_GROUP_TITLE }")` + ); + await expect( duplicatePostbox ).toBeAttached( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( page.locator( '.acf-postbox' ) ).toHaveCount( 2 ); + await expect( + page.locator( `.acf-field[data-name="${ TEXT_FIELD_NAME }"]` ) + ).toHaveCount( 2 ); + } ); + } ); +} ); diff --git a/tests/e2e/field-helpers.js b/tests/e2e/field-helpers.js index e9e7991e..6f1b70aa 100644 --- a/tests/e2e/field-helpers.js +++ b/tests/e2e/field-helpers.js @@ -7,6 +7,24 @@ const PLUGIN_SLUG = 'secure-custom-fields'; +/** + * Purge SCF internal posts via the scf-test-utilities REST endpoint. + * + * Requires the `scf-test-utilities` plugin to be active. Deletes all posts + * of the given SCF internal post types regardless of status. + * + * @param {Object} requestUtils Playwright request utilities. + * @param {string[]} [types] SCF internal post types to purge. Defaults + * to field groups and fields server-side. + */ +async function purgeScfInternalPosts( requestUtils, types ) { + await requestUtils.rest( { + method: 'POST', + path: '/scf-test/v1/purge-internal-posts', + data: types ? { types } : {}, + } ); +} + /** * Delete all field groups and empty trash. * @@ -70,13 +88,13 @@ async function emptyTrash( page, admin ) { /** * Create a new field group with a single field. * - * @param {import('@playwright/test').Page} page Playwright page object. - * @param {Object} admin Admin utilities. - * @param {Object} options Field options. - * @param {string} options.groupTitle Field group title. - * @param {string} options.fieldLabel Field label. - * @param {string} options.fieldType Field type (e.g., 'text', 'image'). - * @param {Function} [options.configure] Optional callback for field configuration. + * @param {import('@playwright/test').Page} page Playwright page object. + * @param {Object} admin Admin utilities. + * @param {Object} options Field options. + * @param {string} options.groupTitle Field group title. + * @param {string} options.fieldLabel Field label. + * @param {string} options.fieldType Field type (e.g., 'text', 'image'). + * @param {Function} [options.configure] Optional callback for field configuration. */ async function createFieldGroup( page, admin, options ) { const { groupTitle, fieldLabel, fieldType, configure } = options; @@ -222,8 +240,8 @@ async function addFieldChoices( page, choices ) { /** * Add a subfield to a repeater, group, or flexible content field. * - * @param {import('@playwright/test').Page} page Playwright page object. - * @param {Object} options Subfield options. + * @param {import('@playwright/test').Page} page Playwright page object. + * @param {Object} options Subfield options. * @param {string} options.label Subfield label. * @param {string} options.type Subfield type. * @param {boolean} [options.isFirst] Whether this is the first subfield. @@ -265,8 +283,8 @@ async function addSubfield( page, options ) { /** * Add a layout to a flexible content field. * - * @param {import('@playwright/test').Page} page Playwright page object. - * @param {Object} options Layout options. + * @param {import('@playwright/test').Page} page Playwright page object. + * @param {Object} options Layout options. * @param {string} options.label Layout label. */ async function addFlexibleContentLayout( page, options ) { @@ -384,10 +402,10 @@ async function scrollIntoViewWithOffset( page, element ) { /** * Select an option in a Select2 AJAX dropdown. * - * @param {import('@playwright/test').Page} page Playwright page object. + * @param {import('@playwright/test').Page} page Playwright page object. * @param {import('@playwright/test').Locator} containerSelector Selector for the Select2 container parent. - * @param {string} searchText Text to search for. - * @param {string} optionText Text of the option to select. + * @param {string} searchText Text to search for. + * @param {string} optionText Text of the option to select. */ async function selectSelect2Option( page, @@ -426,6 +444,7 @@ async function expandFCLayout( layout ) { module.exports = { PLUGIN_SLUG, + purgeScfInternalPosts, deleteFieldGroups, emptyTrash, createFieldGroup, diff --git a/tests/e2e/field-type-image.spec.ts b/tests/e2e/field-type-image.spec.ts index 137eeef5..7e8fc953 100644 --- a/tests/e2e/field-type-image.spec.ts +++ b/tests/e2e/field-type-image.spec.ts @@ -4,7 +4,7 @@ * Tests the Image field which allows users to upload and select * images from the WordPress media library. */ -const { test, expect, wpVersionAtLeast } = require( './fixtures' ); +const { test, expect } = require( './fixtures' ); const { PLUGIN_SLUG, deleteFieldGroups, @@ -32,14 +32,6 @@ test.describe( 'Field Type > Image', () => { test.beforeEach( async ( { page, admin } ) => { await deleteFieldGroups( page, admin ); - - // The WP 7.0 iframed editor breaks the legacy wp.media/plupload flow - // the Image field relies on. Skip until the iframe-aware media fix - // lands in SCF. - test.skip( - await wpVersionAtLeast( page, 7, 0 ), - 'Image field media modal is broken by the WP 7.0 iframed editor; tracked upstream.' - ); } ); test( 'should create an image field and upload an image', async ( { diff --git a/tests/e2e/field-type-url.spec.ts b/tests/e2e/field-type-url.spec.ts index 6f8310ad..d9ae9d67 100644 --- a/tests/e2e/field-type-url.spec.ts +++ b/tests/e2e/field-type-url.spec.ts @@ -83,6 +83,11 @@ test.describe( 'Field Type > URL', () => { // Verify the input await expect( urlInput ).toHaveValue( 'https://wordpress.org' ); + // Persist the metabox value before previewing; previewing an + // unsaved draft can race the metabox save request and render + // the preview without the field value. + await editor.saveDraft(); + // Preview and verify const previewPage = await editor.openPreviewPage(); diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 7c93e076..fcd0da95 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -46,7 +46,9 @@ async function saveCoverage( coverage ) { */ function generateCoverageId( testInfo ) { const testFile = path.basename( testInfo.file, '.spec.ts' ); - const testName = testInfo.title.replace( /[^a-zA-Z0-9]/g, '_' ).slice( 0, 50 ); + const testName = testInfo.title + .replace( /[^a-zA-Z0-9]/g, '_' ) + .slice( 0, 50 ); return `${ testFile }-${ testName }-${ Date.now() }`; } @@ -178,14 +180,16 @@ async function wpVersionAtLeast( page, major, minor ) { return page.evaluate( ( [ maj, min ] ) => { const versionClass = [ ...document.body.classList ].find( ( c ) => - c.startsWith( 'version-' ) + /^(version|branch)-\d+-\d+/.test( c ) ); if ( ! versionClass ) { - return true; + return false; } - const match = versionClass.match( /version-(\d+)-(\d+)/ ); + const match = versionClass.match( + /^(?:version|branch)-(\d+)-(\d+)/ + ); if ( ! match ) { - return true; + return false; } const [ , wpMajor, wpMinor ] = match.map( Number ); return wpMajor > maj || ( wpMajor === maj && wpMinor >= min ); diff --git a/tests/e2e/options-page-admin.spec.ts b/tests/e2e/options-page-admin.spec.ts new file mode 100644 index 00000000..05be394e --- /dev/null +++ b/tests/e2e/options-page-admin.spec.ts @@ -0,0 +1,217 @@ +/** + * E2E tests for UI Options Page admin lifecycle. + * + * Covers creating a UI options page via the SCF admin, verifying it is + * registered in the admin menu, attaching a field group via the + * "Options Page" location rule, saving and persisting a field value on + * the options page, and finally deleting the options page and verifying + * it is removed from the admin menu. + */ +const { test, expect } = require( './fixtures' ); +const { PLUGIN_SLUG, purgeScfInternalPosts } = require( './field-helpers' ); + +const UTILITIES_PLUGIN_SLUG = 'scf-test-utilities'; + +const OPTIONS_PAGE_TITLE = 'E2E Site Settings'; +const OPTIONS_PAGE_SLUG = 'e2e-site-settings'; +const FIELD_GROUP_TITLE = 'E2E Options Page Fields'; +const FIELD_LABEL = 'Site Tagline'; +// Field name is auto-generated from the label by the field group editor. +const FIELD_NAME = 'site_tagline'; +const FIELD_VALUE = 'Hello from the E2E options page'; + +const DEFAULT_TIMEOUT = 5000; + +/** + * Locate the test field's text input on the options page screen. + * + * @param {import('@playwright/test').Page} page Playwright page object. + * @return {import('@playwright/test').Locator} The field input locator. + */ +function optionsFieldInput( page ) { + return page.locator( + `.acf-field[data-name="${ FIELD_NAME }"] input[type="text"]` + ); +} + +/** + * Locate the options page link in the admin menu. + * + * @param {import('@playwright/test').Page} page Playwright page object. + * @return {import('@playwright/test').Locator} The admin menu link locator. + */ +function optionsPageMenuLink( page ) { + return page.locator( '#adminmenu' ).getByRole( 'link', { + name: OPTIONS_PAGE_TITLE, + exact: true, + } ); +} + +test.describe( 'Options Page Admin', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( PLUGIN_SLUG ); + await requestUtils.activatePlugin( UTILITIES_PLUGIN_SLUG ); + // Wipe any leftover SCF internal posts so menu/location assertions + // are not affected by state from previous runs. + await purgeScfInternalPosts( requestUtils, [ + 'acf-field-group', + 'acf-field', + 'acf-ui-options-page', + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await purgeScfInternalPosts( requestUtils, [ + 'acf-field-group', + 'acf-field', + 'acf-ui-options-page', + ] ); + await requestUtils.deactivatePlugin( UTILITIES_PLUGIN_SLUG ); + await requestUtils.deactivatePlugin( PLUGIN_SLUG ); + } ); + + test( 'should create, use, and delete a UI options page', async ( { + page, + admin, + } ) => { + await test.step( 'Create a UI options page via the SCF admin', async () => { + await admin.visitAdminPage( + 'post-new.php', + 'post_type=acf-ui-options-page' + ); + + // Fill slug BEFORE page title to prevent auto-generation by JS. + await page.fill( + '#acf_ui_options_page-menu_slug', + OPTIONS_PAGE_SLUG + ); + await page.fill( + '#acf_ui_options_page-page_title', + OPTIONS_PAGE_TITLE + ); + + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + const createdNotice = page.locator( '.updated.notice' ); + await expect( createdNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( createdNotice ).toContainText( + 'options page created' + ); + } ); + + await test.step( 'Verify the options page is registered in the admin menu', async () => { + await admin.visitAdminPage( 'index.php', '' ); + await expect( optionsPageMenuLink( page ) ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + } ); + + await test.step( 'Create a field group located on the options page', async () => { + await admin.visitAdminPage( + 'edit.php', + 'post_type=acf-field-group' + ); + await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); + + await page.waitForSelector( '#title' ); + await page.fill( '#title', FIELD_GROUP_TITLE ); + + const fieldLabelInput = page.locator( + 'input[id^="acf_fields-field_"][id$="-label"]' + ); + await fieldLabelInput.fill( FIELD_LABEL ); + + // Location rule: Options Page == our new options page. + const paramSelect = page.locator( + 'select[id^="acf_field_group-location-group_0-rule_0-param"]' + ); + await paramSelect.scrollIntoViewIfNeeded(); + await paramSelect.selectOption( 'options_page' ); + + const valueSelect = page.locator( + 'select[id^="acf_field_group-location-group_0-rule_0-value"]' + ); + await expect( + valueSelect.locator( + `option:has-text("${ OPTIONS_PAGE_TITLE }")` + ) + ).toBeAttached( { timeout: DEFAULT_TIMEOUT } ); + await valueSelect.selectOption( OPTIONS_PAGE_SLUG ); + + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + const publishedNotice = page.locator( '.updated.notice' ); + await expect( publishedNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( publishedNotice ).toContainText( + 'Field group published' + ); + } ); + + await test.step( 'Fill the field on the options page and save', async () => { + await admin.visitAdminPage( + 'admin.php', + `page=${ OPTIONS_PAGE_SLUG }` + ); + await expect( + page.locator( '.acf-settings-wrap h1' ) + ).toContainText( OPTIONS_PAGE_TITLE ); + + const optionsField = optionsFieldInput( page ); + await expect( optionsField ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await optionsField.fill( FIELD_VALUE ); + + await page.click( '#publish' ); + + const savedNotice = page.locator( + '.acf-admin-notice.notice-success' + ); + await expect( savedNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( savedNotice ).toContainText( 'Options Updated' ); + } ); + + await test.step( 'Reload the options page and verify persistence', async () => { + await admin.visitAdminPage( + 'admin.php', + `page=${ OPTIONS_PAGE_SLUG }` + ); + await expect( optionsFieldInput( page ) ).toHaveValue( + FIELD_VALUE + ); + } ); + + await test.step( 'Delete the options page', async () => { + await admin.visitAdminPage( + 'edit.php', + 'post_type=acf-ui-options-page' + ); + const optionsPageRow = page.locator( '#the-list tr', { + hasText: OPTIONS_PAGE_TITLE, + } ); + await expect( optionsPageRow ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await optionsPageRow + .locator( 'th.check-column input[type="checkbox"]' ) + .check(); + await page.selectOption( '#bulk-action-selector-bottom', 'trash' ); + await page.click( '#doaction2' ); + + const trashNotice = page.locator( '.updated.notice' ); + await expect( trashNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( trashNotice ).toContainText( 'moved to the Trash' ); + } ); + + await test.step( 'Verify the options page no longer appears in the menu', async () => { + await admin.visitAdminPage( 'index.php', '' ); + await expect( optionsPageMenuLink( page ) ).not.toBeVisible(); + } ); + } ); +} ); diff --git a/tests/e2e/plugins/scf-test-get-field-user.php b/tests/e2e/plugins/scf-test-get-field-user.php index 02453f70..f748f83d 100644 --- a/tests/e2e/plugins/scf-test-get-field-user.php +++ b/tests/e2e/plugins/scf-test-get-field-user.php @@ -7,6 +7,34 @@ * @package scf-test-plugins */ +/** + * Resolve the user ID whose `user_title` field should be rendered. + * + * The target user is configurable via the `scf_test_user_id` query arg + * (validated with absint). When absent, it falls back to the queried + * author, and finally to user 1 (the historical default) so existing + * consumers keep working unchanged. + * + * @return int Target user ID. + */ +function scf_test_get_field_user_target_id() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only E2E test plugin parameter. + if ( isset( $_GET['scf_test_user_id'] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only E2E test plugin parameter. + $user_id = absint( wp_unslash( $_GET['scf_test_user_id'] ) ); + if ( $user_id > 0 ) { + return $user_id; + } + } + + $author_id = absint( get_query_var( 'author' ) ); + if ( $author_id > 0 ) { + return $author_id; + } + + return 1; +} + /** * Add post-formats support to pages * @@ -14,12 +42,14 @@ */ function scf_add_get_field_at_the_end_option() { + $user_ref = 'user_' . scf_test_get_field_user_target_id(); + // Get the field object to validate it exists. - $field_object = get_field_object( 'user_title', 'user_1' ); + $field_object = get_field_object( 'user_title', $user_ref ); // Only proceed if the field exists and is a valid type. if ( $field_object && isset( $field_object['type'] ) && 'text' === $field_object['type'] ) { - $field = get_field( 'user_title', 'user_1' ); + $field = get_field( 'user_title', $user_ref ); // Ensure we have a string value and sanitize it. $field = is_string( $field ) ? sanitize_text_field( $field ) : ''; diff --git a/tests/e2e/plugins/scf-test-utilities.php b/tests/e2e/plugins/scf-test-utilities.php new file mode 100644 index 00000000..71652e1d --- /dev/null +++ b/tests/e2e/plugins/scf-test-utilities.php @@ -0,0 +1,61 @@ + 'POST', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => function ( $request ) { + $types = $request->get_param( 'types' ); + if ( empty( $types ) || ! is_array( $types ) ) { + $types = array( 'acf-field-group', 'acf-field' ); + } + + // Restrict to SCF internal post types only. + $allowed = array( 'acf-field-group', 'acf-field', 'acf-post-type', 'acf-taxonomy', 'acf-ui-options-page' ); + $types = array_values( array_intersect( $types, $allowed ) ); + + $deleted = array(); + $posts = get_posts( + array( + 'post_type' => $types, + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + ) + ); + + foreach ( $posts as $post_id ) { + if ( wp_delete_post( $post_id, true ) ) { + $deleted[] = $post_id; + } + } + + return rest_ensure_response( + array( + 'deleted' => count( $deleted ), + 'ids' => $deleted, + ) + ); + }, + ) + ); + } +); diff --git a/tests/e2e/post-type-admin.spec.ts b/tests/e2e/post-type-admin.spec.ts index c81b5aea..75fb084c 100644 --- a/tests/e2e/post-type-admin.spec.ts +++ b/tests/e2e/post-type-admin.spec.ts @@ -114,133 +114,200 @@ test.describe( 'Post Type Creation', () => { // Clean up any leftover entities from previous test runs await cleanupIntegrationTestEntities( page, admin ); - // SECTION 1: Create a hierarchical taxonomy - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_TAXONOMY_SLUG }` ); - await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); - - await page.fill( '#acf_taxonomy-labels-name', INTEGRATION_TAXONOMY_NAME ); - await page.fill( '#acf_taxonomy-labels-singular_name', INTEGRATION_TAXONOMY_SINGULAR ); - await page.fill( '#acf_taxonomy-taxonomy', INTEGRATION_TAXONOMY_KEY ); - - await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); - await expectSuccessNotice( page, 'taxonomy created' ); - - // SECTION 2: Create hierarchical post type linked to the taxonomy - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_POST_TYPE_SLUG }` ); - await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); - - await page.fill( '#acf_post_type-labels-name', INTEGRATION_POST_TYPE_NAME ); - await page.fill( '#acf_post_type-labels-singular_name', INTEGRATION_POST_TYPE_SINGULAR ); - await page.fill( '#acf_post_type-post_type', INTEGRATION_POST_TYPE_KEY ); - - // Enable hierarchical (like pages) - const hierarchicalToggle = page.locator( '.acf-field[data-name="hierarchical"] .acf-switch' ); - await hierarchicalToggle.click(); - - // Verify hierarchical toggle is now active - await expect( hierarchicalToggle ).toHaveClass( /acf-switch-on|-on/ ); - - // Link to our taxonomy - const taxonomiesField = page.locator( '.acf-field[data-name="taxonomies"]' ); - await taxonomiesField.locator( '.select2-selection' ).click(); - await page.locator( `.select2-results__option:has-text("${ INTEGRATION_TAXONOMY_SINGULAR }")` ).click(); - - await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); - await expectSuccessNotice( page, 'post type created' ); - - // Verify taxonomy link persisted by checking select2 shows it - await expect( - taxonomiesField.locator( `.select2-selection__choice:has-text("${ INTEGRATION_TAXONOMY_SINGULAR }")` ) - ).toBeVisible(); - - // Verify hierarchical setting persisted - await expect( hierarchicalToggle ).toHaveClass( /acf-switch-on|-on/ ); - - // SECTION 3: Verify post type-taxonomy integration on actual post type screen - // First navigate to dashboard to trigger fresh WordPress init (registers post type) - await admin.visitAdminPage( 'index.php', '' ); - - // Verify custom post type appears in admin menu (proves it's registered) - const customPostTypeMenu = page.locator( '#adminmenu' ).getByRole( 'link', { name: INTEGRATION_POST_TYPE_NAME, exact: true } ); - await expect( customPostTypeMenu ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); - - // Navigate to the custom post type's "Add New" screen via menu click - await customPostTypeMenu.click(); - - // Click "Add E2E Project" button (WordPress uses singular label) - await page.locator( `.wrap a:has-text("Add ${ INTEGRATION_POST_TYPE_SINGULAR }")` ).click(); - - // Wait for block editor to load - await page.waitForTimeout( 1000 ); - - // Verify the taxonomy panel appears in the sidebar (block editor displays taxonomies in sidebar) - // The panel uses the taxonomy's plural label (INTEGRATION_TAXONOMY_NAME) - const taxonomyPanel = page.getByRole( 'button', { name: INTEGRATION_TAXONOMY_NAME } ); - await expect( taxonomyPanel ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); - - // SECTION 4: Create field group with location rule for our custom post type - // Fresh page load ensures post type cache is updated - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_FIELD_GROUP_SLUG }` ); - await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); - - await page.fill( '#title', INTEGRATION_FIELD_GROUP_NAME ); - - // Add a text field - const fieldLabelInput = page.locator( 'input[id^="acf_fields-field_"][id$="-label"]' ); - await fieldLabelInput.fill( INTEGRATION_FIELD_LABEL ); - - // Set location rule: Post Type == our custom post type - const paramSelect = page.locator( 'select[id^="acf_field_group-location-group_0-rule_0-param"]' ); - await paramSelect.scrollIntoViewIfNeeded(); - await paramSelect.selectOption( 'post_type' ); - - // Wait for our custom post type to appear in the dropdown (by label text) - const valueSelect = page.locator( 'select[id^="acf_field_group-location-group_0-rule_0-value"]' ); - await expect( valueSelect.locator( `option:has-text("${ INTEGRATION_POST_TYPE_SINGULAR }")` ) ) - .toBeAttached( { timeout: DEFAULT_TIMEOUT } ); - - // Select by label text since the value may be auto-generated - await valueSelect.selectOption( { label: INTEGRATION_POST_TYPE_SINGULAR } ); - - await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); - await expectSuccessNotice( page, 'Field group published' ); - - // SECTION 5: Verify field group appears on custom post type edit screen - // Navigate via menu to ensure post type is fully registered - await admin.visitAdminPage( 'index.php', '' ); - await page.locator( '#adminmenu' ).getByRole( 'link', { name: INTEGRATION_POST_TYPE_NAME, exact: true } ).click(); - await page.locator( `.wrap a:has-text("Add ${ INTEGRATION_POST_TYPE_SINGULAR }")` ).click(); - - await prepareBlockEditorForMetabox( page ); - await verifyFieldInMetabox( page, INTEGRATION_FIELD_GROUP_NAME, INTEGRATION_FIELD_LABEL ); - - // SECTION 6: Verify all entities appear in their admin lists - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_POST_TYPE_SLUG }` ); - await expect( - page.locator( `#the-list a:has-text("${ INTEGRATION_POST_TYPE_NAME }")` ) - ).toBeVisible(); - - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_TAXONOMY_SLUG }` ); - await expect( - page.locator( `#the-list a:has-text("${ INTEGRATION_TAXONOMY_NAME }")` ) - ).toBeVisible(); - - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_FIELD_GROUP_SLUG }` ); - await expect( - page.locator( `#the-list a:has-text("${ INTEGRATION_FIELD_GROUP_NAME }")` ) - ).toBeVisible(); - - // SECTION 7: Clean up - await cleanupIntegrationTestEntities( page, admin ); + await test.step( 'Create a hierarchical taxonomy', async () => { + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_TAXONOMY_SLUG }` + ); + await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); + + await page.fill( + '#acf_taxonomy-labels-name', + INTEGRATION_TAXONOMY_NAME + ); + await page.fill( + '#acf_taxonomy-labels-singular_name', + INTEGRATION_TAXONOMY_SINGULAR + ); + await page.fill( + '#acf_taxonomy-taxonomy', + INTEGRATION_TAXONOMY_KEY + ); + + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + await expectSuccessNotice( page, 'taxonomy created' ); + } ); + + await test.step( 'Create hierarchical post type linked to the taxonomy', async () => { + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_POST_TYPE_SLUG }` + ); + await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); + + await page.fill( + '#acf_post_type-labels-name', + INTEGRATION_POST_TYPE_NAME + ); + await page.fill( + '#acf_post_type-labels-singular_name', + INTEGRATION_POST_TYPE_SINGULAR + ); + await page.fill( + '#acf_post_type-post_type', + INTEGRATION_POST_TYPE_KEY + ); + + // Enable hierarchical (like pages) + const hierarchicalToggle = page.locator( + '.acf-field[data-name="hierarchical"] .acf-switch' + ); + await hierarchicalToggle.click(); + + // Verify hierarchical toggle is now active + await expect( hierarchicalToggle ).toHaveClass( + /acf-switch-on|-on/ + ); + + // Link to our taxonomy + const taxonomiesField = page.locator( + '.acf-field[data-name="taxonomies"]' + ); + await taxonomiesField.locator( '.select2-selection' ).click(); + await page + .locator( + `.select2-results__option:has-text("${ INTEGRATION_TAXONOMY_SINGULAR }")` + ) + .click(); + + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + await expectSuccessNotice( page, 'post type created' ); + + // Verify taxonomy link persisted by checking select2 shows it + await expect( + taxonomiesField.locator( + `.select2-selection__choice:has-text("${ INTEGRATION_TAXONOMY_SINGULAR }")` + ) + ).toBeVisible(); + + // Verify hierarchical setting persisted + await expect( hierarchicalToggle ).toHaveClass( + /acf-switch-on|-on/ + ); + } ); + + await test.step( 'Verify post type-taxonomy integration on the post type screen', async () => { + // Navigate to the custom post type's "Add New" screen via the + // admin menu (a dashboard visit triggers fresh WordPress init + // so the post type is registered). + await visitPostTypeAddNewScreen( page, admin ); + + // Verify the taxonomy panel appears in the sidebar (block + // editor displays taxonomies in sidebar). The panel uses the + // taxonomy's plural label (INTEGRATION_TAXONOMY_NAME). + const taxonomyPanel = page.getByRole( 'button', { + name: INTEGRATION_TAXONOMY_NAME, + } ); + await expect( taxonomyPanel ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + } ); + + await test.step( 'Create field group with location rule for the custom post type', async () => { + // Fresh page load ensures post type cache is updated + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_FIELD_GROUP_SLUG }` + ); + await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); + + await page.fill( '#title', INTEGRATION_FIELD_GROUP_NAME ); + + // Add a text field + const fieldLabelInput = page.locator( + 'input[id^="acf_fields-field_"][id$="-label"]' + ); + await fieldLabelInput.fill( INTEGRATION_FIELD_LABEL ); + + // Set location rule: Post Type == our custom post type + const paramSelect = page.locator( + 'select[id^="acf_field_group-location-group_0-rule_0-param"]' + ); + await paramSelect.scrollIntoViewIfNeeded(); + await paramSelect.selectOption( 'post_type' ); + + // Wait for our custom post type to appear in the dropdown + // (by label text) + const valueSelect = page.locator( + 'select[id^="acf_field_group-location-group_0-rule_0-value"]' + ); + await expect( + valueSelect.locator( + `option:has-text("${ INTEGRATION_POST_TYPE_SINGULAR }")` + ) + ).toBeAttached( { timeout: DEFAULT_TIMEOUT } ); + + // Select by label text since the value may be auto-generated + await valueSelect.selectOption( { + label: INTEGRATION_POST_TYPE_SINGULAR, + } ); + + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + await expectSuccessNotice( page, 'Field group published' ); + } ); + + await test.step( 'Verify field group appears on the custom post type edit screen', async () => { + // Navigate via menu to ensure post type is fully registered + await visitPostTypeAddNewScreen( page, admin, { + prepareEditor: false, + } ); + + await prepareBlockEditorForMetabox( page ); + await verifyFieldInMetabox( + page, + INTEGRATION_FIELD_GROUP_NAME, + INTEGRATION_FIELD_LABEL + ); + } ); + + await test.step( 'Verify all entities appear in their admin lists', async () => { + await expectEntityInAdminList( + page, + admin, + SCF_POST_TYPE_SLUG, + INTEGRATION_POST_TYPE_NAME + ); + await expectEntityInAdminList( + page, + admin, + SCF_TAXONOMY_SLUG, + INTEGRATION_TAXONOMY_NAME + ); + await expectEntityInAdminList( + page, + admin, + SCF_FIELD_GROUP_SLUG, + INTEGRATION_FIELD_GROUP_NAME + ); + } ); + + await test.step( 'Clean up integration entities', async () => { + await cleanupIntegrationTestEntities( page, admin ); + } ); } ); } ); /** * Helper function to create a custom post type + * @param page + * @param admin */ async function createCustomPostType( page, admin ) { // Navigate to post types admin page - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_POST_TYPE_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_POST_TYPE_SLUG }` + ); // Click "Add New" button const addNewButton = page.locator( 'a.acf-btn:has-text("Add New")' ); @@ -248,12 +315,19 @@ async function createCustomPostType( page, admin ) { await addNewButton.click(); // Verify we're on the creation page - await expect( page ).toHaveURL( /.*post-new\.php\?post_type=acf-post-type/ ); - await expect( page.locator( 'div.wrap h1' ) ).toContainText( 'Add New Post Type' ); + await expect( page ).toHaveURL( + /.*post-new\.php\?post_type=acf-post-type/ + ); + await expect( page.locator( 'div.wrap h1' ) ).toContainText( + 'Add New Post Type' + ); // Fill required fields await page.fill( '#acf_post_type-labels-name', POST_TYPE_NAME ); - await page.fill( '#acf_post_type-labels-singular_name', POST_TYPE_SINGULAR ); + await page.fill( + '#acf_post_type-labels-singular_name', + POST_TYPE_SINGULAR + ); // Submit form await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); @@ -264,16 +338,24 @@ async function createCustomPostType( page, admin ) { /** * Helper function to verify post type was created + * @param page + * @param admin */ async function verifyPostTypeCreated( page, admin ) { // Check post type appears in the list - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_POST_TYPE_SLUG }` ); - const postTypeLink = page.locator( `#the-list a:has-text("${ POST_TYPE_NAME }")` ); + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_POST_TYPE_SLUG }` + ); + const postTypeLink = page.locator( + `#the-list a:has-text("${ POST_TYPE_NAME }")` + ); await expect( postTypeLink ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); } /** * Helper function to verify post type shows in admin menu and works + * @param page */ async function verifyPostTypeInAdminMenu( page ) { // Check post type appears in admin menu @@ -282,17 +364,28 @@ async function verifyPostTypeInAdminMenu( page ) { // Navigate to post type admin page await menuItem.click(); - await expect( page.locator( 'h1.wp-heading-inline' ) ).toContainText( POST_TYPE_NAME ); + await expect( page.locator( 'h1.wp-heading-inline' ) ).toContainText( + POST_TYPE_NAME + ); } /** * Helper function to update an existing post type + * @param page + * @param admin + * @param newName + * @param newSingular */ async function updatePostType( page, admin, newName, newSingular ) { - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_POST_TYPE_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_POST_TYPE_SLUG }` + ); // Click on the post type to edit it - const postTypeLink = page.locator( `#the-list a.row-title:has-text("${ POST_TYPE_NAME }")` ); + const postTypeLink = page.locator( + `#the-list a.row-title:has-text("${ POST_TYPE_NAME }")` + ); await expect( postTypeLink ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); await postTypeLink.click(); @@ -312,33 +405,57 @@ async function updatePostType( page, admin, newName, newSingular ) { /** * Helper function to verify post type was updated, not duplicated + * @param page + * @param admin + * @param updatedName */ async function verifyPostTypeUpdated( page, admin, updatedName ) { - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_POST_TYPE_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_POST_TYPE_SLUG }` + ); // Verify the updated post type exists - const updatedPostTypeLink = page.locator( `#the-list a:has-text("${ updatedName }")` ); - await expect( updatedPostTypeLink ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); + const updatedPostTypeLink = page.locator( + `#the-list a:has-text("${ updatedName }")` + ); + await expect( updatedPostTypeLink ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); // Verify the original post type no longer exists - const originalPostTypeLink = page.locator( `#the-list a:has-text("${ POST_TYPE_NAME }")` ); + const originalPostTypeLink = page.locator( + `#the-list a:has-text("${ POST_TYPE_NAME }")` + ); await expect( originalPostTypeLink ).not.toBeVisible(); // Count how many post types with the updated name exist (should be exactly 1) - const postTypeCount = await page.locator( `#the-list a:has-text("${ updatedName }")` ).count(); + const postTypeCount = await page + .locator( `#the-list a:has-text("${ updatedName }")` ) + .count(); expect( postTypeCount ).toBe( 1 ); } /** * Helper function to delete the post type + * @param page + * @param admin + * @param postTypeName */ async function deletePostType( page, admin, postTypeName = POST_TYPE_NAME ) { - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_POST_TYPE_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_POST_TYPE_SLUG }` + ); // Find and select the post type row - const postTypeRow = page.locator( `tr.type-acf-post-type:has(a.row-title:text("${ postTypeName }"))` ); + const postTypeRow = page.locator( + `tr.type-acf-post-type:has(a.row-title:text("${ postTypeName }"))` + ); await expect( postTypeRow ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); - await postTypeRow.locator( 'th.check-column input[type="checkbox"]' ).check(); + await postTypeRow + .locator( 'th.check-column input[type="checkbox"]' ) + .check(); // Use bulk actions to trash the post type await page.selectOption( '#bulk-action-selector-bottom', 'trash' ); @@ -350,14 +467,24 @@ async function deletePostType( page, admin, postTypeName = POST_TYPE_NAME ) { /** * Helper function to restore a post type from trash. + * @param page + * @param admin + * @param postTypeName */ async function restorePostType( page, admin, postTypeName = POST_TYPE_NAME ) { - await admin.visitAdminPage( 'edit.php', `post_status=trash&post_type=${ SCF_POST_TYPE_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_status=trash&post_type=${ SCF_POST_TYPE_SLUG }` + ); // Find the trashed post type row - const postTypeRow = page.locator( '#the-list tr', { hasText: postTypeName } ); + const postTypeRow = page.locator( '#the-list tr', { + hasText: postTypeName, + } ); await expect( postTypeRow ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); - await postTypeRow.locator( 'th.check-column input[type="checkbox"]' ).check(); + await postTypeRow + .locator( 'th.check-column input[type="checkbox"]' ) + .check(); // Use bulk actions to restore await page.selectOption( '#bulk-action-selector-bottom', 'untrash' ); @@ -370,12 +497,22 @@ async function restorePostType( page, admin, postTypeName = POST_TYPE_NAME ) { /** * Helper function to trash all post types created. + * @param page + * @param admin */ async function trashAllPostTypes( page, admin ) { - await admin.visitAdminPage( 'edit.php', `post_status=trash&post_type=${ SCF_POST_TYPE_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_status=trash&post_type=${ SCF_POST_TYPE_SLUG }` + ); - const emptyTrashButton = page.locator( '.tablenav.bottom input[name="delete_all"][value="Empty Trash"]' ); - await emptyTrashButton.waitFor( { state: 'visible', timeout: DEFAULT_TIMEOUT } ); + const emptyTrashButton = page.locator( + '.tablenav.bottom input[name="delete_all"][value="Empty Trash"]' + ); + await emptyTrashButton.waitFor( { + state: 'visible', + timeout: DEFAULT_TIMEOUT, + } ); await emptyTrashButton.click(); const successNotice = page.locator( '.notice.updated p' ); @@ -385,6 +522,9 @@ async function trashAllPostTypes( page, admin ) { /** * Helper function to empty trash for a given post type. + * @param page + * @param admin + * @param postType */ async function emptyTrashForPostType( page, admin, postType ) { await admin.visitAdminPage( @@ -394,7 +534,11 @@ async function emptyTrashForPostType( page, admin, postType ) { const emptyTrashButton = page.locator( '.tablenav.bottom input[name="delete_all"][value="Empty Trash"]' ); - if ( await emptyTrashButton.isVisible( { timeout: 1000 } ).catch( () => false ) ) { + if ( + await emptyTrashButton + .isVisible( { timeout: 1000 } ) + .catch( () => false ) + ) { await emptyTrashButton.click(); await page.waitForLoadState( 'networkidle' ); } @@ -402,6 +546,10 @@ async function emptyTrashForPostType( page, admin, postType ) { /** * Helper function to trash an entity by name. + * @param page + * @param admin + * @param postType + * @param entityName */ async function trashEntityByName( page, admin, postType, entityName ) { await admin.visitAdminPage( 'edit.php', `post_type=${ postType }` ); @@ -419,6 +567,8 @@ async function trashEntityByName( page, admin, postType, entityName ) { /** * Helper function to clean up integration test entities. + * @param page + * @param admin */ async function cleanupIntegrationTestEntities( page, admin ) { // Delete field group @@ -449,8 +599,65 @@ async function cleanupIntegrationTestEntities( page, admin ) { await emptyTrashForPostType( page, admin, SCF_TAXONOMY_SLUG ); } +/** + * Helper to navigate to the integration post type's "Add New" screen via the + * admin menu. Visiting the dashboard first triggers fresh WordPress init so + * the custom post type is registered, and the visible menu link proves it. + * @param page + * @param admin + * @param options + * @param options.prepareEditor Whether to wait for the block editor and close + * any blocking modal after navigating. + */ +async function visitPostTypeAddNewScreen( + page, + admin, + { prepareEditor = true } = {} +) { + await admin.visitAdminPage( 'index.php', '' ); + + // Verify custom post type appears in admin menu (proves it's registered) + const customPostTypeMenu = page.locator( '#adminmenu' ).getByRole( 'link', { + name: INTEGRATION_POST_TYPE_NAME, + exact: true, + } ); + await expect( customPostTypeMenu ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await customPostTypeMenu.click(); + + // Click "Add E2E Project" button (WordPress uses singular label) + await page + .locator( + `.wrap a:has-text("Add ${ INTEGRATION_POST_TYPE_SINGULAR }")` + ) + .click(); + + if ( prepareEditor ) { + // Wait for block editor to load + await page.waitForTimeout( 1000 ); + await closeEditorModal( page ); + } +} + +/** + * Helper to assert an entity appears in its admin list table. + * @param page + * @param admin + * @param postType + * @param entityName + */ +async function expectEntityInAdminList( page, admin, postType, entityName ) { + await admin.visitAdminPage( 'edit.php', `post_type=${ postType }` ); + await expect( + page.locator( `#the-list a:has-text("${ entityName }")` ) + ).toBeVisible(); +} + /** * Helper to verify a success notice appears with expected text. + * @param page + * @param textContains */ async function expectSuccessNotice( page, textContains ) { const notice = page.locator( '.updated.notice' ); @@ -461,26 +668,25 @@ async function expectSuccessNotice( page, textContains ) { /** * Helper to prepare block editor for metabox testing. * Closes pattern modal and expands Meta Boxes panel if needed. + * @param page */ async function prepareBlockEditorForMetabox( page ) { // Wait for page to stabilize await page.waitForTimeout( 1000 ); - // Close pattern modal if it appears - const patternModal = page.locator( '.components-modal__frame' ); - if ( await patternModal.isVisible() ) { - await page.keyboard.press( 'Escape' ); - await expect( patternModal ).not.toBeVisible( { timeout: 3000 } ); - } + await closeEditorModal( page ); // Scroll to bottom to ensure meta boxes area is visible - await page.evaluate( () => window.scrollTo( 0, document.body.scrollHeight ) ); + await page.evaluate( () => + window.scrollTo( 0, document.body.scrollHeight ) + ); await page.waitForTimeout( 500 ); // Expand Meta Boxes section if collapsed const metaBoxesToggle = page.locator( 'button:has-text("Meta Boxes")' ); if ( await metaBoxesToggle.isVisible() ) { - const isExpanded = await metaBoxesToggle.getAttribute( 'aria-expanded' ); + const isExpanded = + await metaBoxesToggle.getAttribute( 'aria-expanded' ); if ( isExpanded === 'false' ) { await metaBoxesToggle.evaluate( ( el ) => el.click() ); await page.waitForTimeout( 500 ); @@ -488,8 +694,26 @@ async function prepareBlockEditorForMetabox( page ) { } } +/** + * Helper to close WordPress editor modals that hide the editor from the + * accessible tree, such as the first-run welcome guide or pattern picker. + * @param page + */ +async function closeEditorModal( page ) { + const editorModal = page.locator( '.components-modal__frame' ); + if ( + await editorModal.isVisible( { timeout: 1000 } ).catch( () => false ) + ) { + await page.keyboard.press( 'Escape' ); + await expect( editorModal ).not.toBeVisible( { timeout: 3000 } ); + } +} + /** * Helper to expand a collapsed metabox and verify field visibility. + * @param page + * @param metaboxName + * @param fieldLabel */ async function verifyFieldInMetabox( page, metaboxName, fieldLabel ) { const metabox = page.locator( `.acf-postbox:has-text("${ metaboxName }")` ); diff --git a/tests/e2e/user-profile-fields.spec.ts b/tests/e2e/user-profile-fields.spec.ts new file mode 100644 index 00000000..9a6b6d6f --- /dev/null +++ b/tests/e2e/user-profile-fields.spec.ts @@ -0,0 +1,187 @@ +/** + * E2E tests for field groups on user profile forms. + * + * Covers field groups with a "User Form" location rule rendering on the + * own-profile screen (profile.php) and on another user's edit screen + * (user-edit.php), saving and persisting values on both, and reading the + * value back via the scf-test get_field() user plugin on the frontend. + */ +const { test, expect } = require( './fixtures' ); +const { PLUGIN_SLUG, purgeScfInternalPosts } = require( './field-helpers' ); + +const UTILITIES_PLUGIN_SLUG = 'scf-test-utilities'; +// Renders `get_field( 'user_title', 'user_' )` on the frontend via +// the_content. The target user is passed via the `scf_test_user_id` query arg. +const GET_FIELD_PLUGIN_SLUG = 'scf-test-plugin-get-field-user-title'; + +const FIELD_GROUP_TITLE = 'E2E User Profile Fields'; +const FIELD_LABEL = 'User Title'; +// Field name is auto-generated from the label by the field group editor. +const FIELD_NAME = 'user_title'; +const PROFILE_VALUE = 'E2E Own Profile Value'; +const OTHER_USER_VALUE = 'E2E Other User Value'; + +const DEFAULT_TIMEOUT = 5000; + +/** + * Create a field group with a text field located on user forms. + * + * @param {import('@playwright/test').Page} page Playwright page object. + * @param {Object} admin Admin utilities. + * @param {string} locationValue User Form rule value ('all' or 'edit'). + */ +async function createUserFieldGroup( page, admin, locationValue ) { + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); + await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); + + await page.waitForSelector( '#title' ); + await page.fill( '#title', FIELD_GROUP_TITLE ); + + const fieldLabelInput = page.locator( + 'input[id^="acf_fields-field_"][id$="-label"]' + ); + await fieldLabelInput.fill( FIELD_LABEL ); + + // Location rule: User Form == locationValue. + await page.selectOption( + 'select[id^="acf_field_group-location-group_0-rule_0-param"]', + 'user_form' + ); + await page.selectOption( + 'select[id^="acf_field_group-location-group_0-rule_0-operator"]', + '==' + ); + await page.selectOption( + 'select[id^="acf_field_group-location-group_0-rule_0-value"]', + locationValue + ); + + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + const successNotice = page.locator( '.updated.notice' ); + await expect( successNotice ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); + await expect( successNotice ).toContainText( 'Field group published' ); +} + +test.describe( 'User Profile Fields', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( PLUGIN_SLUG ); + await requestUtils.activatePlugin( UTILITIES_PLUGIN_SLUG ); + await requestUtils.activatePlugin( GET_FIELD_PLUGIN_SLUG ); + // Remove leftover users from previous runs (keeps the admin user). + await requestUtils.deleteAllUsers(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await purgeScfInternalPosts( requestUtils, [ + 'acf-field-group', + 'acf-field', + ] ); + await requestUtils.deleteAllPosts(); + await requestUtils.deleteAllUsers(); + await requestUtils.deactivatePlugin( GET_FIELD_PLUGIN_SLUG ); + await requestUtils.deactivatePlugin( UTILITIES_PLUGIN_SLUG ); + await requestUtils.deactivatePlugin( PLUGIN_SLUG ); + } ); + + test.beforeEach( async ( { requestUtils } ) => { + await purgeScfInternalPosts( requestUtils, [ + 'acf-field-group', + 'acf-field', + ] ); + } ); + + test( 'should render, save, and persist a field on the own profile screen', async ( { + page, + admin, + requestUtils, + } ) => { + await createUserFieldGroup( page, admin, 'all' ); + + // SECTION 1: Verify the field renders on profile.php. + await admin.visitAdminPage( 'profile.php', '' ); + const profileField = page.locator( + `.acf-field[data-name="${ FIELD_NAME }"] input` + ); + await expect( profileField ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + + // SECTION 2: Fill and save. + await profileField.fill( PROFILE_VALUE ); + await page.click( '#submit' ); + + const updateNotice = page.locator( '.updated.notice' ); + await expect( updateNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( updateNotice ).toContainText( 'Profile updated' ); + + // SECTION 3: Reload and verify persistence. + await admin.visitAdminPage( 'profile.php', '' ); + await expect( + page.locator( `.acf-field[data-name="${ FIELD_NAME }"] input` ) + ).toHaveValue( PROFILE_VALUE ); + + // SECTION 4: Verify the value is readable via get_field() on the + // frontend (the test plugin appends the target user's field value + // to post content on the author archive). Derive the admin's actual + // user ID instead of assuming user 1, and pass it to the plugin via + // the scf_test_user_id query arg. + const adminUser = await requestUtils.rest( { + path: '/wp/v2/users/me', + } ); + await requestUtils.createPost( { + title: 'User Field Frontend Check', + status: 'publish', + } ); + await page.goto( + `/?author=${ adminUser.id }&scf_test_user_id=${ adminUser.id }` + ); + const frontendValue = page.locator( '#scf-test-user-title' ).first(); + await expect( frontendValue ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( frontendValue ).toContainText( + `User title: ${ PROFILE_VALUE }` + ); + } ); + + test( 'should render, save, and persist a field on another user edit screen', async ( { + page, + admin, + requestUtils, + } ) => { + await createUserFieldGroup( page, admin, 'edit' ); + + // Create a second user to edit. + const user = await requestUtils.createUser( { + username: 'scfe2euser', + email: 'scfe2euser@example.com', + password: 'scfE2Epassword!42', + roles: [ 'editor' ], + } ); + + // SECTION 1: Verify the field renders on user-edit.php. + await admin.visitAdminPage( 'user-edit.php', `user_id=${ user.id }` ); + const userField = page.locator( + `.acf-field[data-name="${ FIELD_NAME }"] input` + ); + await expect( userField ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); + + // SECTION 2: Fill and save. + await userField.fill( OTHER_USER_VALUE ); + await page.click( '#submit' ); + + const updateNotice = page.locator( '.updated.notice' ); + await expect( updateNotice ).toBeVisible( { + timeout: DEFAULT_TIMEOUT, + } ); + await expect( updateNotice ).toContainText( 'User updated' ); + + // SECTION 3: Reload and verify persistence. + await admin.visitAdminPage( 'user-edit.php', `user_id=${ user.id }` ); + await expect( + page.locator( `.acf-field[data-name="${ FIELD_NAME }"] input` ) + ).toHaveValue( OTHER_USER_VALUE ); + } ); +} ); diff --git a/tests/js/acf-serialization.test.js b/tests/js/acf-serialization.test.js new file mode 100644 index 00000000..9e1ce5c2 --- /dev/null +++ b/tests/js/acf-serialization.test.js @@ -0,0 +1,570 @@ +/** + * Unit tests for SCF serialization, normalization and stateful helpers + * in _acf.js: + * - acf.serialize() name parsing into nested objects/arrays + * - acf.serializeForAjax() + * - flexible content / numeric-key normalization (SCF 6.6.0 additions) + * - acf.prepareForAjax() + * - user preferences stored in localStorage + * - element locks (acf.lock / unlock / isLocked) + * - visibility helpers (acf.show / hide) and acf.val() + * - acf.renderSelect() output escaping + * - acf.escHtml() sanitization through DOMPurify + */ + +const { createJQueryStub } = require( './mocks/acf-jquery' ); + +/** + * Builds a fake jQuery element whose find().serializeArray() returns the + * given name/value pairs, mimicking form inputs. + * + * @param {Array} inputs Array of { name, value } objects. + * @return {Object} Fake jQuery element. + */ +const createFormStub = ( inputs ) => ( { + find: jest.fn( () => ( { + serializeArray: () => inputs.map( ( input ) => ( { ...input } ) ), + } ) ), +} ); + +describe( 'SCF Serialization and State Helpers', () => { + let acf; + + beforeEach( () => { + window.localStorage.clear(); + global.jQuery = createJQueryStub(); + global.$ = global.jQuery; + + jest.isolateModules( () => { + require( '../../assets/src/js/_acf.js' ); + require( '../../assets/src/js/_acf-hooks.js' ); + } ); + + acf = window.acf; + } ); + + afterEach( () => { + delete window.acf; + delete window.acfL10n; + window.localStorage.clear(); + } ); + + describe( 'serialize()', () => { + it( 'should build a nested object from bracketed names', () => { + const $el = createFormStub( [ + { name: 'acf[field_1]', value: 'one' }, + { name: 'acf[field_2]', value: 'two' }, + ] ); + + expect( acf.serialize( $el ) ).toEqual( { + acf: { field_1: 'one', field_2: 'two' }, + } ); + } ); + + it( 'should collect repeated [] names into arrays', () => { + const $el = createFormStub( [ + { name: 'acf[field_1][]', value: 'a' }, + { name: 'acf[field_1][]', value: 'b' }, + ] ); + + expect( acf.serialize( $el ) ).toEqual( { + acf: { field_1: [ 'a', 'b' ] }, + } ); + } ); + + it( 'should handle deeply nested names', () => { + const $el = createFormStub( [ + { name: 'acf[row_0][sub_field]', value: 'deep' }, + ] ); + + expect( acf.serialize( $el ) ).toEqual( { + acf: { row_0: { sub_field: 'deep' } }, + } ); + } ); + + it( 'should strip the prefix and ignore other inputs', () => { + const $el = createFormStub( [ + { name: 'acf[field_1]', value: 'kept' }, + { name: 'other[field_2]', value: 'dropped' }, + ] ); + + expect( acf.serialize( $el, 'acf' ) ).toEqual( { + field_1: 'kept', + } ); + } ); + } ); + + describe( 'serializeForAjax()', () => { + it( 'should map names to values', () => { + const $el = createFormStub( [ + { name: 'post_title', value: 'Hello' }, + { name: 'post_status', value: 'draft' }, + ] ); + + expect( acf.serializeForAjax( $el ) ).toEqual( { + post_title: 'Hello', + post_status: 'draft', + } ); + } ); + + it( 'should group [] suffixed names into arrays', () => { + const $el = createFormStub( [ + { name: 'terms[]', value: '1' }, + { name: 'terms[]', value: '2' }, + { name: 'single', value: 'x' }, + ] ); + + expect( acf.serializeForAjax( $el ) ).toEqual( { + 'terms[]': [ '1', '2' ], + single: 'x', + } ); + } ); + } ); + + describe( 'isFlexibleContentData()', () => { + it( 'should detect rows containing acf_fc_layout', () => { + const value = { + row_0: { acf_fc_layout: 'hero', title: 'Hi' }, + }; + + expect( acf.isFlexibleContentData( value ) ).toBe( true ); + } ); + + it( 'should skip the acfcloneindex key', () => { + const value = { + acfcloneindex: { acf_fc_layout: 'clone' }, + }; + + expect( acf.isFlexibleContentData( value ) ).toBe( false ); + } ); + + it( 'should return false for plain objects and primitives', () => { + expect( acf.isFlexibleContentData( { a: 1 } ) ).toBe( false ); + expect( acf.isFlexibleContentData( 'string' ) ).toBe( false ); + expect( acf.isFlexibleContentData( 42 ) ).toBe( false ); + } ); + } ); + + describe( 'normalizeFlexibleContentData()', () => { + it( 'should convert numeric-keyed objects to sorted arrays', () => { + const result = acf.normalizeFlexibleContentData( { + checkboxes: { 0: 'one', 2: 'three', 1: 'two' }, + } ); + + expect( result.checkboxes ).toEqual( [ 'one', 'two', 'three' ] ); + } ); + + it( 'should order by numeric value, supporting leading zeros', () => { + const result = acf.normalizeFlexibleContentData( { + values: { '010': 'ten', 2: 'two', '0001': 'one' }, + } ); + + expect( result.values ).toEqual( [ 'one', 'two', 'ten' ] ); + } ); + + it( 'should not convert objects with mixed keys', () => { + const result = acf.normalizeFlexibleContentData( { + mixed: { 0: 'zero', name: 'keep' }, + } ); + + expect( result.mixed ).toEqual( { 0: 'zero', name: 'keep' } ); + } ); + + it( 'should convert flexible content rows to arrays and drop acfcloneindex', () => { + const result = acf.normalizeFlexibleContentData( { + flex: { + acfcloneindex: { acf_fc_layout: 'clone' }, + unique_id_a: { acf_fc_layout: 'hero', title: 'A' }, + unique_id_b: { acf_fc_layout: 'text', body: 'B' }, + }, + } ); + + expect( result.flex ).toEqual( [ + { acf_fc_layout: 'hero', title: 'A' }, + { acf_fc_layout: 'text', body: 'B' }, + ] ); + } ); + + it( 'should recurse into nested structures', () => { + const result = acf.normalizeFlexibleContentData( { + group: { + inner: { 1: 'b', 0: 'a' }, + }, + } ); + + expect( result.group.inner ).toEqual( [ 'a', 'b' ] ); + } ); + + it( 'should pass primitives through unchanged', () => { + const result = acf.normalizeFlexibleContentData( { + title: 'Hello', + count: 5, + } ); + + expect( result ).toEqual( { title: 'Hello', count: 5 } ); + } ); + + it( 'should return non-object input unchanged', () => { + expect( acf.normalizeFlexibleContentData( 'text' ) ).toBe( 'text' ); + } ); + } ); + + describe( 'prepareForAjax()', () => { + beforeEach( () => { + acf.set( 'nonce', 'global-nonce' ); + acf.set( 'post_id', 123 ); + } ); + + it( 'should add the global nonce when none is provided', () => { + const data = acf.prepareForAjax( { action: 'scf/test' } ); + + expect( data.nonce ).toBe( 'global-nonce' ); + expect( data.post_id ).toBe( 123 ); + } ); + + it( 'should keep an existing nonce by default', () => { + const data = acf.prepareForAjax( { nonce: 'custom' } ); + + expect( data.nonce ).toBe( 'custom' ); + } ); + + it( 'should force the global nonce when requested', () => { + const data = acf.prepareForAjax( { nonce: 'custom' }, true ); + + expect( data.nonce ).toBe( 'global-nonce' ); + } ); + + it( 'should add language when set', () => { + expect( acf.prepareForAjax( {} ).lang ).toBeUndefined(); + + acf.set( 'language', 'en_US' ); + + expect( acf.prepareForAjax( {} ).lang ).toBe( 'en_US' ); + } ); + + it( 'should run data through the prepare_for_ajax filter', () => { + acf.addFilter( 'prepare_for_ajax', ( data ) => { + data.filtered = true; + return data; + } ); + + expect( acf.prepareForAjax( {} ).filtered ).toBe( true ); + } ); + } ); + + describe( 'Preferences (localStorage)', () => { + it( 'should store and retrieve a preference', () => { + acf.setPreference( 'sidebar', 'open' ); + + expect( acf.getPreference( 'sidebar' ) ).toBe( 'open' ); + expect( + JSON.parse( window.localStorage.getItem( 'acf' ) ).sidebar + ).toBe( 'open' ); + } ); + + it( 'should return null for unknown preferences', () => { + expect( acf.getPreference( 'unknown' ) ).toBeNull(); + } ); + + it( 'should remove a preference', () => { + acf.setPreference( 'temp', 'value' ); + acf.removePreference( 'temp' ); + + expect( acf.getPreference( 'temp' ) ).toBeNull(); + expect( + JSON.parse( window.localStorage.getItem( 'acf' ) ).temp + ).toBeUndefined(); + } ); + + it( 'should scope "this." preferences to the current post id', () => { + acf.set( 'post_id', 99 ); + acf.setPreference( 'this.collapsed', [ 'field_1' ] ); + + expect( + JSON.parse( window.localStorage.getItem( 'acf' ) )[ + 'collapsed-99' + ] + ).toEqual( [ 'field_1' ] ); + expect( acf.getPreference( 'this.collapsed' ) ).toEqual( [ + 'field_1', + ] ); + } ); + } ); + + describe( 'Locks', () => { + let $el; + + beforeEach( () => { + $el = global.jQuery( { fake: 'element' } ); + } ); + + it( 'should report no lock initially', () => { + expect( acf.isLocked( $el, 'hidden' ) ).toBe( false ); + } ); + + it( 'should lock and unlock with a key', () => { + acf.lock( $el, 'hidden', 'conditional_logic' ); + expect( acf.isLocked( $el, 'hidden' ) ).toBe( true ); + + const unlocked = acf.unlock( $el, 'hidden', 'conditional_logic' ); + expect( unlocked ).toBe( true ); + expect( acf.isLocked( $el, 'hidden' ) ).toBe( false ); + } ); + + it( 'should stay locked until all keys are removed', () => { + acf.lock( $el, 'hidden', 'key1' ); + acf.lock( $el, 'hidden', 'key2' ); + + expect( acf.unlock( $el, 'hidden', 'key1' ) ).toBe( false ); + expect( acf.isLocked( $el, 'hidden' ) ).toBe( true ); + expect( acf.unlock( $el, 'hidden', 'key2' ) ).toBe( true ); + } ); + + it( 'should not add the same key twice', () => { + acf.lock( $el, 'hidden', 'key1' ); + acf.lock( $el, 'hidden', 'key1' ); + + expect( acf.unlock( $el, 'hidden', 'key1' ) ).toBe( true ); + } ); + + it( 'should track lock types independently', () => { + acf.lock( $el, 'hidden', 'key1' ); + + expect( acf.isLocked( $el, 'disabled' ) ).toBe( false ); + } ); + } ); + + describe( 'Visibility helpers', () => { + let $el; + + beforeEach( () => { + $el = global.jQuery( { visible: 'element' } ); + } ); + + it( 'hide() should add the acf-hidden class and report change', () => { + expect( acf.hide( $el ) ).toBe( true ); + expect( $el.hasClass( 'acf-hidden' ) ).toBe( true ); + expect( acf.isHidden( $el ) ).toBe( true ); + expect( acf.isVisible( $el ) ).toBe( false ); + } ); + + it( 'hide() should return false when already hidden', () => { + acf.hide( $el ); + + expect( acf.hide( $el ) ).toBe( false ); + } ); + + it( 'show() should remove the acf-hidden class', () => { + acf.hide( $el ); + + expect( acf.show( $el ) ).toBe( true ); + expect( acf.isVisible( $el ) ).toBe( true ); + } ); + + it( 'show() should return false when not hidden', () => { + expect( acf.show( $el ) ).toBe( false ); + } ); + + it( 'show() should bail while another lock key remains', () => { + acf.hide( $el, 'lock_a' ); + acf.hide( $el, 'lock_b' ); + + expect( acf.show( $el, 'lock_a' ) ).toBe( false ); + expect( acf.isHidden( $el ) ).toBe( true ); + + expect( acf.show( $el, 'lock_b' ) ).toBe( true ); + expect( acf.isHidden( $el ) ).toBe( false ); + } ); + } ); + + describe( 'val()', () => { + const createInput = ( initial, options ) => { + let current = initial; + const input = { + isSelect: !! ( options && options.isSelect ), + allowed: options && options.allowed, + val( value ) { + if ( value === undefined ) { + return current; + } + if ( + this.isSelect && + this.allowed && + this.allowed.indexOf( value ) === -1 + ) { + current = null; + return this; + } + current = value; + return this; + }, + is: ( selector ) => selector === 'select' && input.isSelect, + trigger: jest.fn(), + }; + return input; + }; + + it( 'should update the value and trigger change', () => { + const $input = createInput( 'old' ); + + expect( acf.val( $input, 'new' ) ).toBe( true ); + expect( $input.val() ).toBe( 'new' ); + expect( $input.trigger ).toHaveBeenCalledWith( 'change' ); + } ); + + it( 'should bail when the value is unchanged', () => { + const $input = createInput( 'same' ); + + expect( acf.val( $input, 'same' ) ).toBe( false ); + expect( $input.trigger ).not.toHaveBeenCalled(); + } ); + + it( 'should not trigger change when silent', () => { + const $input = createInput( 'old' ); + + expect( acf.val( $input, 'new', true ) ).toBe( true ); + expect( $input.trigger ).not.toHaveBeenCalled(); + } ); + + it( 'should revert selects when the option does not exist', () => { + const $input = createInput( 'a', { + isSelect: true, + allowed: [ 'a', 'b' ], + } ); + + expect( acf.val( $input, 'missing' ) ).toBe( false ); + expect( $input.val() ).toBe( 'a' ); + expect( $input.trigger ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'renderSelect()', () => { + const createSelect = ( initial ) => { + const state = { value: initial, html: '' }; + return { + state, + val( value ) { + if ( value === undefined ) { + return state.value; + } + state.value = value; + return this; + }, + html( markup ) { + state.html = markup; + return this; + }, + }; + }; + + it( 'should render option elements for choices', () => { + const $select = createSelect( '1' ); + + acf.renderSelect( $select, [ + { id: '1', text: 'One' }, + { id: '2', text: 'Two' }, + ] ); + + expect( $select.state.html ).toBe( + '' + ); + } ); + + it( 'should escape HTML in values and labels', () => { + const $select = createSelect( '' ); + + acf.renderSelect( $select, [ + { id: '">' ) + ).toBe( '

safe

' ); + } ); + + it( 'should strip iframes and forms', () => { + expect( + acf.escHtml( 'ok' ) + ).not.toContain( 'ok' ) ).not.toContain( + ' { + const result = acf.escHtml( 'x' ); + + expect( result ).not.toContain( 'onclick' ); + expect( result ).toContain( ' { + expect( acf.escHtml( '

x

' ) ).toBe( + '

x

' + ); + } ); + + it( 'should keep basic formatting markup', () => { + const markup = 'bold and italic'; + + expect( acf.escHtml( markup ) ).toBe( markup ); + } ); + + it( 'should allow customization via the esc_html_dompurify_config filter', () => { + acf.addFilter( 'esc_html_dompurify_config', ( config ) => { + config.FORBID_TAGS = config.FORBID_TAGS.concat( [ 'em' ] ); + return config; + } ); + + expect( acf.escHtml( 'a b' ) ).toBe( 'a b' ); + } ); + } ); +} ); diff --git a/tests/js/acf-utilities.test.js b/tests/js/acf-utilities.test.js new file mode 100644 index 00000000..7e260f96 --- /dev/null +++ b/tests/js/acf-utilities.test.js @@ -0,0 +1,475 @@ +/** + * Unit tests for SCF core utility functions (_acf.js) + * + * Loads the real _acf.js module (plus _acf-hooks.js for the hook wrappers) + * with a minimal jQuery stub and tests the pure utility API: + * - data accessors (get/set/has) + * - id generators (uniqueId/uniqid) + * - string utilities (case conversion, sanitization, escaping) + * - translation helpers (__ , _x, _n) + * - object/array helpers (isset, isget, objectToArray, uniqueArray) + * - function utilities (debounce, throttle, once) + * - ajax response helpers (isAjaxSuccess, getAjaxMessage, getXhrError) + * - action history wrappers (doingAction, didAction, currentAction) + */ + +const { createJQueryStub } = require( './mocks/acf-jquery' ); + +describe( 'SCF Core Utilities', () => { + let acf; + + beforeEach( () => { + global.jQuery = createJQueryStub(); + global.$ = global.jQuery; + delete window.acfL10n; + + jest.isolateModules( () => { + require( '../../assets/src/js/_acf.js' ); + require( '../../assets/src/js/_acf-hooks.js' ); + } ); + + acf = window.acf; + } ); + + afterEach( () => { + delete window.acf; + delete window.acfL10n; + } ); + + describe( 'Data accessors', () => { + it( 'should set and get data values', () => { + acf.set( 'my_key', 'my_value' ); + + expect( acf.get( 'my_key' ) ).toBe( 'my_value' ); + } ); + + it( 'should return null for unknown keys', () => { + expect( acf.get( 'unknown' ) ).toBeNull(); + } ); + + it( 'should return null for falsy stored values', () => { + // NOTE: documents current behavior — acf.get() uses `|| null`, + // so stored falsy values (0, '', false) are unreadable. + // Tracked in #461. + acf.set( 'zero', 0 ); + + expect( acf.get( 'zero' ) ).toBeNull(); + expect( acf.has( 'zero' ) ).toBe( false ); + } ); + + it( 'should support chaining on set()', () => { + expect( acf.set( 'a', 1 ) ).toBe( acf ); + } ); + + it( 'has() should reflect existence of truthy values', () => { + acf.set( 'present', 'yes' ); + + expect( acf.has( 'present' ) ).toBe( true ); + expect( acf.has( 'absent' ) ).toBe( false ); + } ); + } ); + + describe( 'ID generators', () => { + it( 'uniqueId() should increment on each call', () => { + const first = acf.uniqueId(); + const second = acf.uniqueId(); + + expect( parseInt( second, 10 ) ).toBe( parseInt( first, 10 ) + 1 ); + } ); + + it( 'uniqueId() should apply a prefix', () => { + expect( acf.uniqueId( 'acf' ) ).toMatch( /^acf\d+$/ ); + } ); + + it( 'uniqid() should return a 13 character id by default', () => { + expect( acf.uniqid() ).toHaveLength( 13 ); + } ); + + it( 'uniqid() should prepend the prefix', () => { + const id = acf.uniqid( 'foo' ); + + expect( id ).toHaveLength( 16 ); + expect( id.startsWith( 'foo' ) ).toBe( true ); + } ); + + it( 'uniqid() should add entropy when requested', () => { + expect( acf.uniqid( 'bar', true ) ).toHaveLength( 23 + 3 ); + } ); + + it( 'uniqid() should not generate duplicate ids consecutively', () => { + expect( acf.uniqid() ).not.toBe( acf.uniqid() ); + } ); + } ); + + describe( 'String utilities', () => { + it( 'strReplace() should replace all occurrences', () => { + expect( acf.strReplace( '_', '-', 'a_b_c' ) ).toBe( 'a-b-c' ); + } ); + + it( 'strCamelCase() should convert snake_case', () => { + expect( acf.strCamelCase( 'date_time_picker' ) ).toBe( + 'dateTimePicker' + ); + } ); + + it( 'strCamelCase() should convert kebab-case', () => { + expect( acf.strCamelCase( 'show-field' ) ).toBe( 'showField' ); + } ); + + it( 'strCamelCase() should return empty string for no matches', () => { + expect( acf.strCamelCase( '___' ) ).toBe( '' ); + } ); + + it( 'strPascalCase() should uppercase the first letter', () => { + expect( acf.strPascalCase( 'google_map' ) ).toBe( 'GoogleMap' ); + } ); + + it( 'strSlugify() should lowercase and replace underscores', () => { + expect( acf.strSlugify( 'Field_Type_Name' ) ).toBe( + 'field-type-name' + ); + } ); + + it( 'strSanitize() should transliterate accented characters', () => { + expect( acf.strSanitize( 'Café Münü' ) ).toBe( 'cafe_munu' ); + } ); + + it( 'strSanitize() should strip punctuation and replace spaces', () => { + expect( acf.strSanitize( "it's a (test)?" ) ).toBe( 'its_a_test' ); + } ); + + it( 'strSanitize() should keep case when toLowerCase is false', () => { + expect( acf.strSanitize( 'My Field', false ) ).toBe( 'My_Field' ); + } ); + + it( 'strMatch() should count leading matching characters', () => { + expect( acf.strMatch( 'hello', 'help' ) ).toBe( 3 ); + expect( acf.strMatch( 'abc', 'xyz' ) ).toBe( 0 ); + expect( acf.strMatch( 'same', 'same' ) ).toBe( 4 ); + } ); + + it( 'strEscape() should escape HTML special characters', () => { + expect( acf.strEscape( '' ) ).toBe( + '<script>"a" & 'b'</script>' + ); + } ); + + it( 'strEscape() should cast non-strings to string', () => { + expect( acf.strEscape( 123 ) ).toBe( '123' ); + } ); + + it( 'strUnescape() should reverse strEscape()', () => { + const original = 'Tom & "Jerry"'; + + expect( acf.strUnescape( acf.strEscape( original ) ) ).toBe( + original + ); + } ); + + it( 'escAttr should be an alias of strEscape', () => { + expect( acf.escAttr ).toBe( acf.strEscape ); + } ); + } ); + + describe( 'parseArgs()', () => { + it( 'should merge args over defaults', () => { + const result = acf.parseArgs( { b: 2 }, { a: 1, b: 'default' } ); + + expect( result ).toEqual( { a: 1, b: 2 } ); + } ); + + it( 'should treat non-object args as empty', () => { + expect( acf.parseArgs( 'nope', { a: 1 } ) ).toEqual( { a: 1 } ); + expect( acf.parseArgs( undefined, { a: 1 } ) ).toEqual( { a: 1 } ); + } ); + + it( 'should not mutate the defaults object', () => { + const defaults = { a: 1 }; + acf.parseArgs( { a: 2 }, defaults ); + + expect( defaults.a ).toBe( 1 ); + } ); + } ); + + describe( 'Translation helpers', () => { + it( '__() should return the text when no translation exists', () => { + expect( acf.__( 'Hello' ) ).toBe( 'Hello' ); + } ); + + it( '__() should return the translation from acfL10n', () => { + window.acfL10n.Hello = 'Hola'; + + expect( acf.__( 'Hello' ) ).toBe( 'Hola' ); + } ); + + it( '_x() should prefer the contextual translation', () => { + window.acfL10n[ 'Post.noun' ] = 'Entrada'; + window.acfL10n.Post = 'Publicar'; + + expect( acf._x( 'Post', 'noun' ) ).toBe( 'Entrada' ); + expect( acf._x( 'Post', 'verb' ) ).toBe( 'Publicar' ); + } ); + + it( '_n() should select singular or plural by number', () => { + expect( acf._n( 'One item', 'Many items', 1 ) ).toBe( 'One item' ); + expect( acf._n( 'One item', 'Many items', 0 ) ).toBe( + 'Many items' + ); + expect( acf._n( 'One item', 'Many items', 5 ) ).toBe( + 'Many items' + ); + } ); + } ); + + describe( 'Type checks and object helpers', () => { + it( 'isArray() should detect arrays only', () => { + expect( acf.isArray( [] ) ).toBe( true ); + expect( acf.isArray( {} ) ).toBe( false ); + expect( acf.isArray( 'a' ) ).toBe( false ); + } ); + + it( 'isObject() should detect objects', () => { + expect( acf.isObject( {} ) ).toBe( true ); + expect( acf.isObject( [] ) ).toBe( true ); + expect( acf.isObject( 'a' ) ).toBe( false ); + // NOTE: documents current behavior — possible bug: + // typeof null === 'object', so isObject( null ) returns true. + // Tracked in #461. + expect( acf.isObject( null ) ).toBe( true ); + } ); + + it( 'isNumeric() should detect numbers and numeric strings', () => { + expect( acf.isNumeric( 12 ) ).toBe( true ); + expect( acf.isNumeric( '12' ) ).toBe( true ); + expect( acf.isNumeric( '1.5' ) ).toBe( true ); + expect( acf.isNumeric( '-3' ) ).toBe( true ); + expect( acf.isNumeric( 'abc' ) ).toBe( false ); + expect( acf.isNumeric( '' ) ).toBe( false ); + expect( acf.isNumeric( null ) ).toBe( false ); + expect( acf.isNumeric( Infinity ) ).toBe( false ); + } ); + + it( 'isset() should check nested property existence', () => { + const obj = { a: { b: { c: false } } }; + + expect( acf.isset( obj, 'a', 'b', 'c' ) ).toBe( true ); + expect( acf.isset( obj, 'a', 'x' ) ).toBe( false ); + expect( acf.isset( null, 'a' ) ).toBe( false ); + } ); + + it( 'isget() should return the nested value or null', () => { + const obj = { a: { b: 'value' } }; + + expect( acf.isget( obj, 'a', 'b' ) ).toBe( 'value' ); + expect( acf.isget( obj, 'a', 'missing' ) ).toBeNull(); + } ); + + it( 'objectToArray() should return object values', () => { + expect( acf.objectToArray( { a: 1, b: 2 } ) ).toEqual( [ 1, 2 ] ); + } ); + + it( 'uniqueArray() should remove duplicate values', () => { + expect( acf.uniqueArray( [ 1, 2, 2, 3, 1 ] ) ).toEqual( [ + 1, 2, 3, + ] ); + } ); + + it( 'arrayArgs() should convert array-like objects', () => { + const args = ( function () { + return acf.arrayArgs( arguments ); + } )( 'a', 'b' ); + + expect( args ).toEqual( [ 'a', 'b' ] ); + expect( Array.isArray( args ) ).toBe( true ); + } ); + } ); + + describe( 'Function utilities', () => { + beforeEach( () => { + jest.useFakeTimers(); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'debounce() should run only the last call after the wait', () => { + const callback = jest.fn(); + const debounced = acf.debounce( callback, 100 ); + + debounced( 'first' ); + debounced( 'second' ); + jest.advanceTimersByTime( 99 ); + expect( callback ).not.toHaveBeenCalled(); + + jest.advanceTimersByTime( 1 ); + expect( callback ).toHaveBeenCalledTimes( 1 ); + expect( callback ).toHaveBeenCalledWith( 'second' ); + } ); + + it( 'throttle() should run immediately then ignore calls within the limit', () => { + const callback = jest.fn(); + const throttled = acf.throttle( callback, 100 ); + + throttled( 'first' ); + throttled( 'ignored' ); + expect( callback ).toHaveBeenCalledTimes( 1 ); + expect( callback ).toHaveBeenCalledWith( 'first' ); + + jest.advanceTimersByTime( 100 ); + throttled( 'second' ); + expect( callback ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'once() should only invoke the function a single time', () => { + const callback = jest.fn( () => 'result' ); + const onced = acf.once( callback ); + + expect( onced() ).toBe( 'result' ); + expect( onced() ).toBeUndefined(); + expect( onced() ).toBeUndefined(); + expect( callback ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( 'Ajax helpers', () => { + it( 'isAjaxSuccess() should require a truthy success property', () => { + expect( acf.isAjaxSuccess( { success: true } ) ).toBe( true ); + expect( acf.isAjaxSuccess( { success: false } ) ).toBe( false ); + expect( acf.isAjaxSuccess( null ) ).toBeFalsy(); + } ); + + it( 'getAjaxMessage() should read data.message', () => { + expect( acf.getAjaxMessage( { data: { message: 'Saved' } } ) ).toBe( + 'Saved' + ); + expect( acf.getAjaxMessage( {} ) ).toBeNull(); + } ); + + it( 'getAjaxError() should read data.error', () => { + expect( acf.getAjaxError( { data: { error: 'Bad' } } ) ).toBe( + 'Bad' + ); + expect( acf.getAjaxError( {} ) ).toBeNull(); + } ); + + it( 'getXhrError() should prefer responseJSON.message', () => { + expect( + acf.getXhrError( { + responseJSON: { message: 'WP_Error message' }, + } ) + ).toBe( 'WP_Error message' ); + } ); + + it( 'getXhrError() should fall back to responseJSON.data.error', () => { + expect( + acf.getXhrError( { + responseJSON: { data: { error: 'json error' } }, + } ) + ).toBe( 'json error' ); + } ); + + it( 'getXhrError() should fall back to statusText', () => { + expect( acf.getXhrError( { statusText: 'Not Found' } ) ).toBe( + 'Not Found' + ); + } ); + + it( 'getXhrError() should return empty string when nothing matches', () => { + expect( acf.getXhrError( {} ) ).toBe( '' ); + } ); + } ); + + describe( 'Action history wrappers', () => { + it( 'didAction() should be false before and true after doAction()', () => { + expect( acf.didAction( 'my_action' ) ).toBe( false ); + + acf.doAction( 'my_action' ); + + expect( acf.didAction( 'my_action' ) ).toBe( true ); + } ); + + it( 'doingAction() should only be true while the action runs', () => { + let doingDuringCallback = null; + + acf.addAction( 'my_action', () => { + doingDuringCallback = acf.doingAction( 'my_action' ); + } ); + + expect( acf.doingAction( 'my_action' ) ).toBe( false ); + acf.doAction( 'my_action' ); + + expect( doingDuringCallback ).toBe( true ); + expect( acf.doingAction( 'my_action' ) ).toBe( false ); + } ); + + it( 'currentAction() should return the running action name', () => { + let current = null; + + acf.addAction( 'running_action', () => { + current = acf.currentAction(); + } ); + acf.doAction( 'running_action' ); + + expect( current ).toBe( 'running_action' ); + expect( acf.currentAction() ).toBe( false ); + } ); + + it( 'addAction()/doAction() should pass arguments through acf.hooks', () => { + const callback = jest.fn(); + + acf.addAction( 'pass_args', callback ); + acf.doAction( 'pass_args', 'a', 'b' ); + + expect( callback ).toHaveBeenCalledWith( 'a', 'b' ); + } ); + + it( 'applyFilters() should chain through registered filters', () => { + acf.addFilter( 'my_filter', ( value ) => value + 1 ); + acf.addFilter( 'my_filter', ( value ) => value * 10, 5 ); + + expect( acf.applyFilters( 'my_filter', 2 ) ).toBe( 21 ); + } ); + + it( 'removeAction() should unregister a callback', () => { + const callback = jest.fn(); + + acf.addAction( 'removable', callback ); + acf.removeAction( 'removable', callback ); + acf.doAction( 'removable' ); + + expect( callback ).not.toHaveBeenCalled(); + } ); + + it( 'removeFilter() should unregister a filter', () => { + const filter = ( value ) => value + '-changed'; + + acf.addFilter( 'removable_filter', filter ); + acf.removeFilter( 'removable_filter', filter ); + + expect( acf.applyFilters( 'removable_filter', 'value' ) ).toBe( + 'value' + ); + } ); + } ); + + describe( 'encode() / decode()', () => { + it( 'encode() should convert HTML to entities', () => { + expect( acf.encode( 'bold' ) ).toBe( + '<b>bold</b>' + ); + } ); + + it( 'decode() should convert entities back to HTML', () => { + expect( acf.decode( '<b>bold</b>' ) ).toBe( + 'bold' + ); + } ); + + it( 'decode() should reverse encode()', () => { + const original = 'Fish & "Chips" now'; + + expect( acf.decode( acf.encode( original ) ) ).toBe( original ); + } ); + } ); +} ); diff --git a/tests/js/compatibility.test.js b/tests/js/compatibility.test.js new file mode 100644 index 00000000..c6e1df97 --- /dev/null +++ b/tests/js/compatibility.test.js @@ -0,0 +1,286 @@ +/** + * Unit tests for the SCF backwards-compatibility layer (_acf-compatibility.js) + * + * The compatibility layer keeps legacy snake_case APIs working by injecting + * a prototype between the acf object and its original prototype. It is a + * common regression point during backports, so the pure parts are covered: + * - acf.newCompatibility() / acf.getCompatibility() prototype wiring + * - legacy function aliases (acf.update, acf.add_action, ...) + * - legacy selector building (acf.get_selector) + * - legacy translation helper (acf._e) + * - maybe_get() dot-path lookups + * - add_action() multi-action registration and $field argument conversion + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); +const { createJQueryStub } = require( './mocks/acf-jquery' ); + +const compatibilitySource = fs.readFileSync( + path.resolve( __dirname, '../../assets/src/js/_acf-compatibility.js' ), + 'utf8' +); + +describe( 'SCF Compatibility Layer', () => { + let acf; + + beforeEach( () => { + global.jQuery = createJQueryStub(); + global.$ = global.jQuery; + + jest.isolateModules( () => { + require( '../../assets/src/js/_acf.js' ); + require( '../../assets/src/js/_acf-hooks.js' ); + require( '../../assets/src/js/_acf-model.js' ); + } ); + + // _acf-compatibility.js wraps these objects at load time; in the + // real bundle they come from modules out of scope for this test. + window.acf.validation = {}; + window.acf.screen = { check: jest.fn(), set: jest.fn() }; + + // The production webpack bundle executes this module in sloppy + // (non-strict) mode and the legacy code relies on it: maybe_get() + // assigns to an undeclared `keys` variable and add_action() relies + // on `arguments` aliasing its named parameters. babel-jest compiles + // required modules to strict mode which breaks both, so evaluate + // the raw source instead to match production semantics. + // The strict-mode fragility is tracked in #460. + // eslint-disable-next-line no-eval + ( 0, eval )( compatibilitySource ); + + acf = window.acf; + + // In production acf.Field is defined by _acf-field.js before any + // legacy callback runs; the compatibility wrapper checks + // `arg instanceof acf.Field` at call time. + acf.Field = function () {}; + } ); + + afterEach( () => { + delete window.acf; + delete window.acfL10n; + } ); + + describe( 'newCompatibility() / getCompatibility()', () => { + it( 'should inject the layer into the prototype chain', () => { + const instance = { own: true }; + const layer = acf.newCompatibility( instance, { + legacy: 'value', + } ); + + expect( instance.legacy ).toBe( 'value' ); + expect( instance.own ).toBe( true ); + expect( Object.getPrototypeOf( instance ) ).toBe( layer ); + } ); + + it( 'should not shadow own properties of the instance', () => { + const instance = { name: 'real' }; + acf.newCompatibility( instance, { name: 'legacy' } ); + + expect( instance.name ).toBe( 'real' ); + } ); + + it( 'getCompatibility() should return the registered layer', () => { + const instance = {}; + const layer = acf.newCompatibility( instance, {} ); + + expect( acf.getCompatibility( instance ) ).toBe( layer ); + expect( acf.getCompatibility( {} ) ).toBeNull(); + } ); + + it( 'should register a compatibility layer on the acf object itself', () => { + expect( acf.getCompatibility( acf ) ).not.toBeNull(); + } ); + } ); + + describe( 'Legacy aliases', () => { + it( 'should alias renamed functions to their new versions', () => { + expect( acf.update ).toBe( acf.set ); + expect( acf.add_action ).not.toBeUndefined(); + expect( acf.do_action ).toBe( acf.doAction ); + expect( acf.apply_filters ).toBe( acf.applyFilters ); + expect( acf.parse_args ).toBe( acf.parseArgs ); + expect( acf.str_replace ).toBe( acf.strReplace ); + expect( acf.esc_html ).toBe( acf.strEscape ); + expect( acf.str_sanitize ).toBe( acf.strSanitize ); + expect( acf.get_uniqid ).toBe( acf.uniqid ); + expect( acf.serialize_form ).toBe( acf.serialize ); + expect( acf.is_ajax_success ).toBe( acf.isAjaxSuccess ); + } ); + + it( 'legacy storage objects should exist', () => { + expect( acf.l10n ).toEqual( {} ); + expect( acf.o ).toEqual( {} ); + } ); + } ); + + describe( '_e()', () => { + it( 'should translate known compatibility keys', () => { + expect( acf._e( 'image', 'select' ) ).toBe( 'Select Image' ); + expect( acf._e( 'image', 'edit' ) ).toBe( 'Edit Image' ); + expect( acf._e( 'image', 'update' ) ).toBe( 'Update Image' ); + } ); + + it( 'should read from the legacy l10n storage', () => { + acf.l10n.address = 'Address'; + + expect( acf._e( 'address' ) ).toBe( 'Address' ); + } ); + + it( 'should read nested l10n values with two keys', () => { + acf.l10n.relationship = { max: 'Maximum reached' }; + + expect( acf._e( 'relationship', 'max' ) ).toBe( 'Maximum reached' ); + } ); + + it( 'should return an empty string for unknown keys', () => { + expect( acf._e( 'missing' ) ).toBe( '' ); + expect( acf._e( 'missing', 'nope' ) ).toBe( '' ); + // NOTE: documents current behavior — possible bug: when k1 is + // unknown, _e() indexes into the empty string, so a k2 naming a + // String.prototype method (e.g. 'sub') returns that function + // instead of ''. Tracked in #461. + expect( typeof acf._e( 'missing', 'sub' ) ).toBe( 'function' ); + } ); + } ); + + describe( 'get_selector()', () => { + it( 'should return the base selector with no argument', () => { + expect( acf.get_selector() ).toBe( '.acf-field' ); + } ); + + it( 'should append the field type', () => { + expect( acf.get_selector( 'image' ) ).toBe( '.acf-field-image' ); + } ); + + it( 'should convert underscores to dashes', () => { + expect( acf.get_selector( 'date_picker' ) ).toBe( + '.acf-field-date-picker' + ); + } ); + + it( 'should de-duplicate the field- prefix for field keys', () => { + expect( acf.get_selector( 'field_123abc' ) ).toBe( + '.acf-field-123abc' + ); + } ); + + it( 'should accept a legacy object argument', () => { + expect( acf.get_selector( { type: 'select' } ) ).toBe( + '.acf-field-select' + ); + expect( acf.get_selector( {} ) ).toBe( '.acf-field' ); + } ); + } ); + + describe( 'maybe_get()', () => { + const obj = { a: { b: { c: 'found' } }, top: 'level' }; + + it( 'should resolve dot-separated paths', () => { + expect( acf.maybe_get( obj, 'a.b.c' ) ).toBe( 'found' ); + expect( acf.maybe_get( obj, 'top' ) ).toBe( 'level' ); + } ); + + it( 'should return null by default for missing paths', () => { + expect( acf.maybe_get( obj, 'a.missing' ) ).toBeNull(); + } ); + + it( 'should return the provided default for missing paths', () => { + expect( acf.maybe_get( obj, 'a.missing', 'fallback' ) ).toBe( + 'fallback' + ); + } ); + } ); + + describe( 'add_action()', () => { + it( 'should register and fire a legacy action', () => { + const callback = jest.fn(); + + acf.add_action( 'legacy_action', callback ); + acf.doAction( 'legacy_action', 'arg' ); + + expect( callback ).toHaveBeenCalledWith( 'arg' ); + } ); + + it( 'should register multiple space-separated actions', () => { + const callback = jest.fn(); + + acf.add_action( 'ready append', callback ); + acf.doAction( 'ready', 'a' ); + acf.doAction( 'append', 'b' ); + + expect( callback ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should convert acf.Field instances to their $el', () => { + // The legacy API expected jQuery elements, not Field objects. + acf.Field = function () { + this.$el = 'the-element'; + }; + const field = new acf.Field(); + const callback = jest.fn(); + + acf.add_action( 'show_field', callback ); + acf.doAction( 'show_field', field ); + + expect( callback ).toHaveBeenCalledWith( 'the-element' ); + } ); + + it( 'should pass $(document) when the action has no arguments', () => { + const callback = jest.fn(); + + acf.add_action( 'ready', callback ); + acf.doAction( 'ready' ); + + expect( callback ).toHaveBeenCalledWith( + global.jQuery( document ) + ); + } ); + } ); + + describe( 'add_filter()', () => { + it( 'should register and apply a legacy filter', () => { + acf.add_filter( 'legacy_filter', ( value ) => value + 1 ); + + expect( acf.applyFilters( 'legacy_filter', 1 ) ).toBe( 2 ); + } ); + } ); + + describe( 'Legacy model', () => { + it( 'extend() should merge properties and register actions', () => { + const onReady = jest.fn(); + + const model = acf.model.extend( { + label: 'legacy', + actions: { custom_event: 'onCustom' }, + onCustom: onReady, + } ); + + expect( model.label ).toBe( 'legacy' ); + + acf.doAction( 'custom_event', 'payload' ); + expect( onReady ).toHaveBeenCalledWith( 'payload' ); + } ); + + it( 'get()/set() should read and write model properties', () => { + const model = acf.model.extend( {} ); + + expect( model.get( 'missing' ) ).toBeNull(); + expect( model.get( 'missing', 'fallback' ) ).toBe( 'fallback' ); + + model.set( 'name', 'value' ); + expect( model.get( 'name' ) ).toBe( 'value' ); + } ); + + it( 'set() should invoke the matching _set_ callback', () => { + const model = acf.model.extend( { + _set_status: jest.fn(), + } ); + + model.set( 'status', 'ready' ); + + expect( model._set_status ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/tests/js/fuzz/acf-fuzz.test.js b/tests/js/fuzz/acf-fuzz.test.js new file mode 100644 index 00000000..a68d3dac Binary files /dev/null and b/tests/js/fuzz/acf-fuzz.test.js differ diff --git a/tests/js/hooks.test.js b/tests/js/hooks.test.js new file mode 100644 index 00000000..d8bf7f0b --- /dev/null +++ b/tests/js/hooks.test.js @@ -0,0 +1,301 @@ +/** + * Unit tests for the SCF internal hook/filter system (_acf-hooks.js) + * + * Tests the EventManager which powers acf.addAction / acf.addFilter etc. + * This is pure logic with no DOM dependency, and a frequent backport + * surface, so it gets thorough coverage: + * - action registration and execution order by priority + * - filter value chaining + * - callback/context-specific removal + * - argument passing and context binding + */ + +describe( 'SCF Hooks (EventManager)', () => { + let hooks; + + beforeEach( () => { + global.acf = {}; + jest.isolateModules( () => { + require( '../../assets/src/js/_acf-hooks.js' ); + } ); + hooks = global.acf.hooks; + } ); + + afterEach( () => { + delete global.acf; + } ); + + describe( 'addAction() / doAction()', () => { + it( 'should execute a registered action callback', () => { + const callback = jest.fn(); + + hooks.addAction( 'test.action', callback ); + hooks.doAction( 'test.action' ); + + expect( callback ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should pass all arguments to the callback', () => { + const callback = jest.fn(); + + hooks.addAction( 'test.action', callback ); + hooks.doAction( 'test.action', 'one', 2, { three: true } ); + + expect( callback ).toHaveBeenCalledWith( 'one', 2, { + three: true, + } ); + } ); + + it( 'should execute callbacks in priority order (lowest first)', () => { + const order = []; + + hooks.addAction( 'test.action', () => order.push( 'late' ), 20 ); + hooks.addAction( 'test.action', () => order.push( 'early' ), 5 ); + hooks.addAction( 'test.action', () => order.push( 'default' ) ); + hooks.doAction( 'test.action' ); + + expect( order ).toEqual( [ 'early', 'default', 'late' ] ); + } ); + + it( 'should preserve registration order for equal priorities', () => { + const order = []; + + hooks.addAction( 'test.action', () => order.push( 'first' ), 10 ); + hooks.addAction( 'test.action', () => order.push( 'second' ), 10 ); + hooks.addAction( 'test.action', () => order.push( 'third' ), 10 ); + hooks.doAction( 'test.action' ); + + expect( order ).toEqual( [ 'first', 'second', 'third' ] ); + } ); + + it( 'should default the priority to 10', () => { + const order = []; + + hooks.addAction( 'test.action', () => order.push( 'default' ) ); + hooks.addAction( 'test.action', () => order.push( 'nine' ), 9 ); + hooks.addAction( 'test.action', () => order.push( 'eleven' ), 11 ); + hooks.doAction( 'test.action' ); + + expect( order ).toEqual( [ 'nine', 'default', 'eleven' ] ); + } ); + + it( 'should parse string priorities as integers', () => { + const order = []; + + hooks.addAction( 'test.action', () => order.push( 'b' ), '20' ); + hooks.addAction( 'test.action', () => order.push( 'a' ), '5' ); + hooks.doAction( 'test.action' ); + + expect( order ).toEqual( [ 'a', 'b' ] ); + } ); + + it( 'should bind the supplied context as `this`', () => { + const context = { name: 'scf' }; + let receivedThis = null; + + hooks.addAction( + 'test.action', + function () { + receivedThis = this; + }, + 10, + context + ); + hooks.doAction( 'test.action' ); + + expect( receivedThis ).toBe( context ); + } ); + + it( 'should ignore registration when callback is not a function', () => { + hooks.addAction( 'test.action', 'not-a-function' ); + + expect( hooks.storage().actions[ 'test.action' ] ).toBeUndefined(); + } ); + + it( 'should ignore registration when action name is not a string', () => { + hooks.addAction( 123, jest.fn() ); + + expect( Object.keys( hooks.storage().actions ) ).toHaveLength( 0 ); + } ); + + it( 'should do nothing when running an unregistered action', () => { + expect( () => hooks.doAction( 'never.registered' ) ).not.toThrow(); + } ); + + it( 'should ignore doAction calls with a non-string action', () => { + const callback = jest.fn(); + hooks.addAction( 'test.action', callback ); + + hooks.doAction( 42 ); + + expect( callback ).not.toHaveBeenCalled(); + } ); + + it( 'should support chaining', () => { + const result = hooks + .addAction( 'test.action', jest.fn() ) + .doAction( 'test.action' ); + + expect( result ).toBe( hooks ); + } ); + } ); + + describe( 'removeAction()', () => { + it( 'should remove all callbacks when no callback is given', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + hooks.addAction( 'test.action', callback1 ); + hooks.addAction( 'test.action', callback2 ); + hooks.removeAction( 'test.action' ); + hooks.doAction( 'test.action' ); + + expect( callback1 ).not.toHaveBeenCalled(); + expect( callback2 ).not.toHaveBeenCalled(); + } ); + + it( 'should remove only the given callback', () => { + const removed = jest.fn(); + const kept = jest.fn(); + + hooks.addAction( 'test.action', removed ); + hooks.addAction( 'test.action', kept ); + hooks.removeAction( 'test.action', removed ); + hooks.doAction( 'test.action' ); + + expect( removed ).not.toHaveBeenCalled(); + expect( kept ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should remove every registration of the same callback', () => { + const callback = jest.fn(); + + hooks.addAction( 'test.action', callback, 5 ); + hooks.addAction( 'test.action', callback, 20 ); + hooks.removeAction( 'test.action', callback ); + hooks.doAction( 'test.action' ); + + expect( callback ).not.toHaveBeenCalled(); + } ); + + it( 'should not throw when removing an unknown action', () => { + expect( () => hooks.removeAction( 'unknown' ) ).not.toThrow(); + } ); + } ); + + describe( 'addFilter() / applyFilters()', () => { + it( 'should return the first argument unmodified when no filters exist', () => { + expect( hooks.applyFilters( 'unknown.filter', 'value' ) ).toBe( + 'value' + ); + } ); + + it( 'should apply a filter to the value', () => { + hooks.addFilter( 'test.filter', ( value ) => value + '-filtered' ); + + expect( hooks.applyFilters( 'test.filter', 'value' ) ).toBe( + 'value-filtered' + ); + } ); + + it( 'should chain filter results through each callback', () => { + hooks.addFilter( 'test.filter', ( value ) => value * 2 ); + hooks.addFilter( 'test.filter', ( value ) => value + 1 ); + + expect( hooks.applyFilters( 'test.filter', 10 ) ).toBe( 21 ); + } ); + + it( 'should apply filters in priority order', () => { + hooks.addFilter( 'test.filter', ( value ) => value + 'b', 20 ); + hooks.addFilter( 'test.filter', ( value ) => value + 'a', 5 ); + + expect( hooks.applyFilters( 'test.filter', '' ) ).toBe( 'ab' ); + } ); + + it( 'should pass extra arguments to every callback', () => { + const callback = jest.fn( ( value ) => value ); + + hooks.addFilter( 'test.filter', callback ); + hooks.applyFilters( 'test.filter', 'value', 'extra1', 'extra2' ); + + expect( callback ).toHaveBeenCalledWith( + 'value', + 'extra1', + 'extra2' + ); + } ); + + it( 'should bind the supplied context as `this`', () => { + const context = { multiplier: 3 }; + + hooks.addFilter( + 'test.filter', + function ( value ) { + return value * this.multiplier; + }, + 10, + context + ); + + expect( hooks.applyFilters( 'test.filter', 2 ) ).toBe( 6 ); + } ); + + it( 'should return the methods object when the filter name is not a string', () => { + // NOTE: documents current behavior — a non-string filter name + // returns the methods object rather than the value. + const result = hooks.applyFilters( 42, 'value' ); + + expect( result ).toBe( hooks ); + } ); + } ); + + describe( 'removeFilter()', () => { + it( 'should remove all callbacks when no callback is given', () => { + hooks.addFilter( 'test.filter', ( value ) => value + '-changed' ); + hooks.removeFilter( 'test.filter' ); + + expect( hooks.applyFilters( 'test.filter', 'value' ) ).toBe( + 'value' + ); + } ); + + it( 'should remove only the given callback', () => { + const removed = ( value ) => value + '-removed'; + const kept = ( value ) => value + '-kept'; + + hooks.addFilter( 'test.filter', removed ); + hooks.addFilter( 'test.filter', kept ); + hooks.removeFilter( 'test.filter', removed ); + + expect( hooks.applyFilters( 'test.filter', 'value' ) ).toBe( + 'value-kept' + ); + } ); + } ); + + describe( 'storage()', () => { + it( 'should expose separate action and filter containers', () => { + hooks.addAction( 'my.action', jest.fn() ); + hooks.addFilter( 'my.filter', jest.fn() ); + + const storage = hooks.storage(); + + expect( storage.actions[ 'my.action' ] ).toHaveLength( 1 ); + expect( storage.filters[ 'my.filter' ] ).toHaveLength( 1 ); + expect( storage.actions[ 'my.filter' ] ).toBeUndefined(); + } ); + + it( 'should store callback, priority and context for each hook', () => { + const callback = jest.fn(); + const context = {}; + + hooks.addAction( 'my.action', callback, 15, context ); + + expect( hooks.storage().actions[ 'my.action' ][ 0 ] ).toEqual( { + callback, + priority: 15, + context, + } ); + } ); + } ); +} ); diff --git a/tests/js/mocks/acf-jquery.js b/tests/js/mocks/acf-jquery.js new file mode 100644 index 00000000..c1389057 --- /dev/null +++ b/tests/js/mocks/acf-jquery.js @@ -0,0 +1,135 @@ +/** + * Minimal jQuery stub for loading SCF core source modules + * (_acf.js, _acf-hooks.js, _acf-model.js, etc.) in jsdom. + * + * Provides just enough of the jQuery API for the IIFE modules to load and + * for tests to assert against. Wrappers are cached per target so that + * repeated calls like jQuery( window ) return the same spy-able object. + * + * Not a real jQuery: DOM traversal is not implemented. Tests that need + * real DOM behavior (e.g. acf.encode/decode) get it via the + * '' creation special-case which uses jsdom elements. + */ + +/* global jest */ + +/** + * Creates a fresh jQuery stub function. Call once per test (or test file) + * and assign to global.jQuery / global.$ before requiring source modules. + * + * @return {Function} The jQuery stub. + */ +function createJQueryStub() { + const wrappers = new Map(); + + const createWrapper = ( target ) => { + const dataStore = {}; + const classes = new Set(); + const wrapper = { + target, + length: 0, + on: jest.fn( () => wrapper ), + off: jest.fn( () => wrapper ), + ready: jest.fn( () => wrapper ), + trigger: jest.fn( () => wrapper ), + triggerHandler: jest.fn( () => wrapper ), + each: jest.fn( () => wrapper ), + remove: jest.fn( () => wrapper ), + find: jest.fn( () => createWrapper( 'find:' + target ) ), + data( key, value ) { + if ( value === undefined ) { + return dataStore[ key ]; + } + dataStore[ key ] = value; + return wrapper; + }, + hasClass: ( name ) => classes.has( name ), + addClass( name ) { + classes.add( name ); + return wrapper; + }, + removeClass( name ) { + classes.delete( name ); + return wrapper; + }, + }; + return wrapper; + }; + + const jq = function ( selector ) { + // Creation syntax, e.g. $('