From d0fd07a348d337fc32487ec48a7e551d5cea8b66 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:40:50 +0200 Subject: [PATCH 1/6] Add comprehensive regression-test battery across PHPUnit, Jest, and E2E PHPUnit 2261 -> 2788 tests (+527, +2074 assertions) covering previously untested flows: public template API (get_field/update_field/have_rows row API), upgrade migrations, revision save/restore, local JSON/fields/meta, Meta storage backends, ACF_Data, validation, options pages, abilities (incl. schema-robustness repros), blocks PHP (registration/render/ bindings), AJAX handlers, Site Health, and misc functions. Jest 772 -> 946 tests (+174) for the hooks system, core utilities, serialization, the legacy compatibility layer, and unload warnings. E2E: +4 tests (options page UI lifecycle, field group duplication, user profile fields), a REST purge utility plugin for suite isolation, and fixes for two order/timing-dependent specs (abilities-fields, url preview race). Known-behavior findings are documented in-test with NOTE comments and left unfixed to keep this change purely additive. Co-Authored-By: Claude Fable 5 --- .eslintignore | 14 + jest.config.js | 1 + tests/e2e/abilities-fields.spec.ts | 9 + tests/e2e/field-group-duplication.spec.ts | 253 ++++ tests/e2e/field-type-image.spec.ts | 10 +- tests/e2e/field-type-url.spec.ts | 5 + tests/e2e/fixtures.js | 14 +- tests/e2e/options-page-admin.spec.ts | 198 ++++ tests/e2e/plugins/scf-test-utilities.php | 60 + tests/e2e/post-type-admin.spec.ts | 325 +++++- tests/e2e/user-profile-fields.spec.ts | 187 +++ tests/js/acf-serialization.test.js | 570 +++++++++ tests/js/acf-utilities.test.js | 473 ++++++++ tests/js/compatibility.test.js | 285 +++++ tests/js/hooks.test.js | 301 +++++ tests/js/mocks/acf-jquery.js | 135 +++ tests/js/unload.test.js | 154 +++ .../test-scf-abilities-integration.php | 230 ++++ .../test-scf-field-group-abilities.php | 515 +++++++++ ...test-scf-list-fields-schema-robustness.php | 387 +++++++ .../test-scf-post-type-abilities.php | 284 +++++ .../test-scf-ui-options-page-abilities.php | 284 +++++ .../ajax/test-ajax-check-screen-response.php | 303 +++++ .../test-ajax-local-json-diff-response.php | 209 ++++ .../ajax/test-ajax-query-users-results.php | 347 ++++++ .../test-ajax-user-setting-persistence.php | 202 ++++ tests/php/includes/api/test-api-helpers.php | 332 ++++++ .../includes/api/test-api-template-rows.php | 950 +++++++++++++++ tests/php/includes/api/test-api-template.php | 712 ++++++++++++ .../test-blocks-auto-inline-editing.php | 420 +++++++ .../blocks/test-blocks-bindings-editor.php | 109 ++ .../includes/blocks/test-blocks-bindings.php | 398 +++++++ .../includes/blocks/test-blocks-render.php | 707 ++++++++++++ tests/php/includes/blocks/test-blocks.php | 1025 +++++++++++++++++ .../class-scf-test-dummy-instance.php | 12 + .../functions/test-acf-post-functions.php | 229 ++++ .../functions/test-acf-user-functions.php | 285 +++++ .../class-scf-test-custom-meta-location.php | 26 + .../php/includes/meta/test-meta-locations.php | 873 ++++++++++++++ tests/php/includes/test-class-acf-data.php | 482 ++++++++ .../includes/test-class-acf-options-page.php | 549 +++++++++ .../includes/test-class-acf-site-health.php | 424 +++++++ tests/php/includes/test-legacy-locations.php | 153 +++ tests/php/includes/test-local-fields.php | 595 ++++++++++ tests/php/includes/test-local-json.php | 783 +++++++++++++ tests/php/includes/test-local-meta.php | 465 ++++++++ tests/php/includes/test-revisions.php | 540 +++++++++ tests/php/includes/test-upgrades.php | 774 +++++++++++++ tests/php/includes/test-validation.php | 698 +++++++++++ .../includes/test-walker-taxonomy-field.php | 188 +++ 50 files changed, 17406 insertions(+), 78 deletions(-) create mode 100644 .eslintignore create mode 100644 tests/e2e/field-group-duplication.spec.ts create mode 100644 tests/e2e/options-page-admin.spec.ts create mode 100644 tests/e2e/plugins/scf-test-utilities.php create mode 100644 tests/e2e/user-profile-fields.spec.ts create mode 100644 tests/js/acf-serialization.test.js create mode 100644 tests/js/acf-utilities.test.js create mode 100644 tests/js/compatibility.test.js create mode 100644 tests/js/hooks.test.js create mode 100644 tests/js/mocks/acf-jquery.js create mode 100644 tests/js/unload.test.js create mode 100644 tests/php/includes/abilities/test-scf-abilities-integration.php create mode 100644 tests/php/includes/abilities/test-scf-field-group-abilities.php create mode 100644 tests/php/includes/abilities/test-scf-list-fields-schema-robustness.php create mode 100644 tests/php/includes/abilities/test-scf-post-type-abilities.php create mode 100644 tests/php/includes/abilities/test-scf-ui-options-page-abilities.php create mode 100644 tests/php/includes/ajax/test-ajax-check-screen-response.php create mode 100644 tests/php/includes/ajax/test-ajax-local-json-diff-response.php create mode 100644 tests/php/includes/ajax/test-ajax-query-users-results.php create mode 100644 tests/php/includes/ajax/test-ajax-user-setting-persistence.php create mode 100644 tests/php/includes/api/test-api-helpers.php create mode 100644 tests/php/includes/api/test-api-template-rows.php create mode 100644 tests/php/includes/api/test-api-template.php create mode 100644 tests/php/includes/blocks/test-blocks-auto-inline-editing.php create mode 100644 tests/php/includes/blocks/test-blocks-bindings-editor.php create mode 100644 tests/php/includes/blocks/test-blocks-bindings.php create mode 100644 tests/php/includes/blocks/test-blocks-render.php create mode 100644 tests/php/includes/blocks/test-blocks.php create mode 100644 tests/php/includes/class-scf-test-dummy-instance.php create mode 100644 tests/php/includes/functions/test-acf-post-functions.php create mode 100644 tests/php/includes/functions/test-acf-user-functions.php create mode 100644 tests/php/includes/meta/class-scf-test-custom-meta-location.php create mode 100644 tests/php/includes/meta/test-meta-locations.php create mode 100644 tests/php/includes/test-class-acf-data.php create mode 100644 tests/php/includes/test-class-acf-options-page.php create mode 100644 tests/php/includes/test-class-acf-site-health.php create mode 100644 tests/php/includes/test-legacy-locations.php create mode 100644 tests/php/includes/test-local-fields.php create mode 100644 tests/php/includes/test-local-json.php create mode 100644 tests/php/includes/test-local-meta.php create mode 100644 tests/php/includes/test-revisions.php create mode 100644 tests/php/includes/test-upgrades.php create mode 100644 tests/php/includes/test-validation.php create mode 100644 tests/php/includes/test-walker-taxonomy-field.php 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..5015ca8e 100644 --- a/tests/e2e/abilities-fields.spec.ts +++ b/tests/e2e/abilities-fields.spec.ts @@ -195,6 +195,15 @@ 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 requestUtils.rest( { + method: 'POST', + path: '/scf-test/v1/purge-fields', + } ); + // Create parent field group await fieldGroupApi.cleanup( requestUtils, TEST_FIELD_GROUP.key ); const fieldGroup = await fieldGroupApi.create( requestUtils ); diff --git a/tests/e2e/field-group-duplication.spec.ts b/tests/e2e/field-group-duplication.spec.ts new file mode 100644 index 00000000..46db2b4c --- /dev/null +++ b/tests/e2e/field-group-duplication.spec.ts @@ -0,0 +1,253 @@ +/** + * 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 } = 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; + +/** + * Purge SCF internal posts via the scf-test-utilities REST endpoint. + * + * @param {Object} requestUtils Playwright request utilities. + * @param {string[]} types SCF internal post types to purge. + */ +async function purgeScfPosts( requestUtils, types ) { + await requestUtils.rest( { + method: 'POST', + path: '/scf-test/v1/purge-fields', + data: { types }, + } ); +} + +/** + * 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 purgeScfPosts( requestUtils, [ 'acf-field-group', 'acf-field' ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await purgeScfPosts( 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, + } ) => { + // SECTION 1: Create a field group with a text field and a select + // field with choices. The default location rule (Post Type == Post) + // is kept so both groups can later render on a post edit screen. + 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 originalKeys = await getFieldKeys( page ); + expect( originalKeys ).toHaveLength( 2 ); + for ( const key of originalKeys ) { + expect( key ).toMatch( /^field_/ ); + } + + // SECTION 2: Duplicate the field group from the list row action. + 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 } ); + + // SECTION 3: Open the duplicate and verify title, fields, settings, + // and that all field keys are new. + 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 ); + } + + // SECTION 4: Verify the original group is untouched. + 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 ); + + // SECTION 5: Verify both groups function independently by rendering + // their own metaboxes on a post edit screen. + 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-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..faf158c8 --- /dev/null +++ b/tests/e2e/options-page-admin.spec.ts @@ -0,0 +1,198 @@ +/** + * 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 } = 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; + +/** + * Purge SCF internal posts via the scf-test-utilities REST endpoint. + * + * @param {Object} requestUtils Playwright request utilities. + * @param {string[]} types SCF internal post types to purge. + */ +async function purgeScfPosts( requestUtils, types ) { + await requestUtils.rest( { + method: 'POST', + path: '/scf-test/v1/purge-fields', + data: { types }, + } ); +} + +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 purgeScfPosts( requestUtils, [ + 'acf-field-group', + 'acf-field', + 'acf-ui-options-page', + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await purgeScfPosts( 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, + } ) => { + // SECTION 1: Create a UI options page via the SCF admin. + 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' ); + + // SECTION 2: Verify the options page is registered in the admin menu. + await admin.visitAdminPage( 'index.php', '' ); + const menuLink = page.locator( '#adminmenu' ).getByRole( 'link', { + name: OPTIONS_PAGE_TITLE, + exact: true, + } ); + await expect( menuLink ).toBeVisible( { timeout: DEFAULT_TIMEOUT } ); + + // SECTION 3: Create a field group located on the options page. + 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' + ); + + // SECTION 4: Fill the field on the options page and save. + await admin.visitAdminPage( + 'admin.php', + `page=${ OPTIONS_PAGE_SLUG }` + ); + await expect( page.locator( '.acf-settings-wrap h1' ) ).toContainText( + OPTIONS_PAGE_TITLE + ); + + const optionsField = page.locator( + `.acf-field[data-name="${ FIELD_NAME }"] input[type="text"]` + ); + 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' ); + + // SECTION 5: Reload the options page and verify persistence. + await admin.visitAdminPage( + 'admin.php', + `page=${ OPTIONS_PAGE_SLUG }` + ); + await expect( + page.locator( + `.acf-field[data-name="${ FIELD_NAME }"] input[type="text"]` + ) + ).toHaveValue( FIELD_VALUE ); + + // SECTION 6: Delete the options page. + 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' ); + + // SECTION 7: Verify the options page no longer appears in the menu. + await admin.visitAdminPage( 'index.php', '' ); + await expect( + page.locator( '#adminmenu' ).getByRole( 'link', { + name: OPTIONS_PAGE_TITLE, + exact: true, + } ) + ).not.toBeVisible(); + } ); +} ); diff --git a/tests/e2e/plugins/scf-test-utilities.php b/tests/e2e/plugins/scf-test-utilities.php new file mode 100644 index 00000000..38cdee67 --- /dev/null +++ b/tests/e2e/plugins/scf-test-utilities.php @@ -0,0 +1,60 @@ + '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..ac5bc3e9 100644 --- a/tests/e2e/post-type-admin.spec.ts +++ b/tests/e2e/post-type-admin.spec.ts @@ -115,42 +115,73 @@ test.describe( 'Post Type Creation', () => { await cleanupIntegrationTestEntities( page, admin ); // SECTION 1: Create a hierarchical taxonomy - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_TAXONOMY_SLUG }` ); + 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-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 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 ); + 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' ); + 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"]' ); + 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 + .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 }")` ) + taxonomiesField.locator( + `.select2-selection__choice:has-text("${ INTEGRATION_TAXONOMY_SINGULAR }")` + ) ).toBeVisible(); // Verify hierarchical setting persisted @@ -161,46 +192,76 @@ test.describe( 'Post Type Creation', () => { 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 } ); + 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(); + await page + .locator( + `.wrap a:has-text("Add ${ INTEGRATION_POST_TYPE_SINGULAR }")` + ) + .click(); // Wait for block editor to load await page.waitForTimeout( 1000 ); + await closeEditorModal( page ); // 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 } ); + 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 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"]' ); + 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"]' ); + 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 } ); + 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 valueSelect.selectOption( { + label: INTEGRATION_POST_TYPE_SINGULAR, + } ); await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); await expectSuccessNotice( page, 'Field group published' ); @@ -208,26 +269,55 @@ test.describe( 'Post Type Creation', () => { // 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 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 ); + 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 admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_POST_TYPE_SLUG }` + ); await expect( - page.locator( `#the-list a:has-text("${ INTEGRATION_POST_TYPE_NAME }")` ) + page.locator( + `#the-list a:has-text("${ INTEGRATION_POST_TYPE_NAME }")` + ) ).toBeVisible(); - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_TAXONOMY_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_TAXONOMY_SLUG }` + ); await expect( - page.locator( `#the-list a:has-text("${ INTEGRATION_TAXONOMY_NAME }")` ) + page.locator( + `#the-list a:has-text("${ INTEGRATION_TAXONOMY_NAME }")` + ) ).toBeVisible(); - await admin.visitAdminPage( 'edit.php', `post_type=${ SCF_FIELD_GROUP_SLUG }` ); + await admin.visitAdminPage( + 'edit.php', + `post_type=${ SCF_FIELD_GROUP_SLUG }` + ); await expect( - page.locator( `#the-list a:has-text("${ INTEGRATION_FIELD_GROUP_NAME }")` ) + page.locator( + `#the-list a:has-text("${ INTEGRATION_FIELD_GROUP_NAME }")` + ) ).toBeVisible(); // SECTION 7: Clean up @@ -237,10 +327,15 @@ test.describe( 'Post Type Creation', () => { /** * 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 +343,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 +366,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 +392,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 +433,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 +495,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 +525,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 +550,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 +562,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 +574,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 +595,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 @@ -451,6 +629,8 @@ async function cleanupIntegrationTestEntities( page, admin ) { /** * 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 +641,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 +667,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..7141c618 --- /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 } = require( './field-helpers' ); + +const UTILITIES_PLUGIN_SLUG = 'scf-test-utilities'; +// Renders `get_field( 'user_title', 'user_1' )` on the frontend via the_content. +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; + +/** + * Purge SCF internal posts via the scf-test-utilities REST endpoint. + * + * @param {Object} requestUtils Playwright request utilities. + * @param {string[]} types SCF internal post types to purge. + */ +async function purgeScfPosts( requestUtils, types ) { + await requestUtils.rest( { + method: 'POST', + path: '/scf-test/v1/purge-fields', + data: { types }, + } ); +} + +/** + * 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 purgeScfPosts( 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 purgeScfPosts( 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 user_1 field value to + // post content on the author archive). + await requestUtils.createPost( { + title: 'User Field Frontend Check', + status: 'publish', + } ); + await page.goto( '/?author=1' ); + 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..352eed06 --- /dev/null +++ b/tests/js/acf-utilities.test.js @@ -0,0 +1,473 @@ +/** + * 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. + 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. + 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..0a5c5caa --- /dev/null +++ b/tests/js/compatibility.test.js @@ -0,0 +1,285 @@ +/** + * 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. + // 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 ''. + 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/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. $('