From 370a33dedcc4266b05509a0ed51f38be76d7e533 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 17 Sep 2025 10:40:04 -0400 Subject: [PATCH 01/10] feat: Authorize AJAX with application passwords Include authorization header in AJAX requets, as we do not have cookies to send in the mobile app environment. --- src/utils/ajax.js | 77 ++++++++++++++++++++ src/utils/editor-environment.js | 4 +- src/utils/videopress-bridge.js | 124 -------------------------------- 3 files changed, 79 insertions(+), 126 deletions(-) create mode 100644 src/utils/ajax.js delete mode 100644 src/utils/videopress-bridge.js diff --git a/src/utils/ajax.js b/src/utils/ajax.js new file mode 100644 index 000000000..8d9bd76db --- /dev/null +++ b/src/utils/ajax.js @@ -0,0 +1,77 @@ +/** + * Internal dependencies + */ +import { getGBKit } from './bridge'; +import { warn, debug } from './logger'; + +/** + * GutenbergKit lacks authentication cookies required for AJAX requests. + * This configures a root URL and authentication header for AJAX requests. + * + * @return {void} + */ +export function initializeAjax() { + window.wp = window.wp || {}; + window.wp.ajax = window.wp.ajax || {}; + window.wp.ajax.settings = window.wp.ajax.settings || {}; + + const { siteURL, authHeader } = getGBKit(); + configureAjaxUrl( siteURL ); + configureAjaxAuth( authHeader ); +} + +function configureAjaxUrl( siteURL ) { + if ( ! siteURL ) { + warn( 'Unable to configure AJAX URL without siteURL' ); + return; + } + + window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; + + debug( 'AJAX URL configured' ); +} + +function configureAjaxAuth( authHeader ) { + if ( ! authHeader ) { + warn( 'Unable to configure AJAX auth without authHeader' ); + return; + } + + window.jQuery?.ajaxSetup( { + headers: { + Authorization: authHeader, + }, + } ); + + const originalSend = window.wp.ajax.send; + window.wp.ajax.send = function ( options ) { + const originalBeforeSend = options.beforeSend; + + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); + + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + + return originalSend.call( this, options ); + }; + + const originalPost = window.wp.ajax.post; + window.wp.ajax.post = function ( options ) { + const originalBeforeSend = options.beforeSend; + + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); + + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + + return originalPost.call( this, options ); + }; + + debug( 'AJAX auth configured' ); +} diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 4470638a6..3115f7c25 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -9,7 +9,7 @@ import { } from './bridge'; import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; -import { initializeVideoPressAjaxBridge } from './videopress-bridge'; +import { initializeAjax } from './ajax'; import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; import { setLogLevel, error } from './logger'; @@ -33,7 +33,6 @@ export async function setUpEditorEnvironment() { await configureLocale(); await initializeWordPressGlobals(); await configureApiFetch(); - initializeVideoPressAjaxBridge(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -138,6 +137,7 @@ async function loadPluginsIfEnabled() { * @return {Promise} Promise that resolves when the editor is initialized */ async function initializeEditor( pluginLoadResult = {} ) { + initializeAjax(); const { initializeEditor: _initializeEditor } = await import( './editor' ); _initializeEditor( { allowedBlockTypes: pluginLoadResult.allowedBlockTypes, diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js deleted file mode 100644 index 7bed2ee2b..000000000 --- a/src/utils/videopress-bridge.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Internal dependencies - */ -import { getGBKit } from './bridge'; -import { warn, debug, error } from './logger'; - -/** - * VideoPress AJAX to REST API bridge. - * - * GutenbergKit lacks authentication cookies required for AJAX requests. - * This module overrides wp.media.ajax to bridge specific VideoPress AJAX - * requests to their corresponding REST API endpoints. - */ - -/** - * Initializes the VideoPress AJAX bridge. - * - * This function overrides wp.media.ajax to intercept VideoPress-specific - * AJAX requests and redirect them to the appropriate REST API endpoints. - * - * @return {void} - */ -export function initializeVideoPressAjaxBridge() { - // Ensure necessary globals are available - if ( ! window.wp || ! window.wp.apiFetch ) { - warn( 'VideoPress bridge: wp.apiFetch not available' ); - return; - } - - // Initialize wp.ajax if not already present - window.wp.ajax = window.wp.ajax || {}; - window.wp.ajax.settings = window.wp.ajax.settings || {}; - - // Set up AJAX settings with site URL - const { siteURL } = getGBKit(); - if ( siteURL ) { - window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; - } - - // Store original wp.media.ajax function if it exists - const originalMediaAjax = window.wp.media?.ajax; - - // Override wp.media.ajax - window.wp.media = window.wp.media || {}; - window.wp.media.ajax = ( ...args ) => { - const [ action ] = args; - - // Handle VideoPress upload JWT request - if ( action === 'videopress-get-upload-jwt' ) { - return handleVideoPressUploadJWT(); - } - - // Fall back to original function or default behavior - if ( originalMediaAjax ) { - return originalMediaAjax( ...args ); - } - - // If no original function exists, return a rejected promise - const deferred = - window.jQuery?.Deferred?.() || createFallbackDeferred(); - deferred.reject( new Error( `Unhandled AJAX action: ${ action }` ) ); - return deferred.promise(); - }; - - debug( 'VideoPress AJAX bridge initialized' ); -} - -/** - * Handles the VideoPress upload JWT request by calling the REST API. - * - * @return {Promise} jQuery Deferred promise that resolves with the JWT response. - */ -function handleVideoPressUploadJWT() { - const deferred = window.jQuery?.Deferred?.() || createFallbackDeferred(); - - window.wp - .apiFetch( { - path: '/wpcom/v2/videopress/upload-jwt', - method: 'POST', - } ) - .then( ( response ) => { - if ( response.error ) { - deferred.reject( response.error ); - } else { - // Transform the response to match expected AJAX format - const processedResponse = { - ...response, - upload_action_url: response.upload_url, - }; - delete processedResponse.upload_url; - - debug( - 'VideoPress JWT obtained successfully', - processedResponse - ); - deferred.resolve( processedResponse ); - } - } ) - .catch( ( err ) => { - error( 'VideoPress JWT request failed', err ); - deferred.reject( err ); - } ); - - return deferred.promise(); -} - -/** - * Creates a fallback deferred object if jQuery is not available. - * - * @return {Object} Deferred-like object with resolve, reject, and promise methods. - */ -function createFallbackDeferred() { - let resolveCallback, rejectCallback; - const promise = new Promise( ( resolve, reject ) => { - resolveCallback = resolve; - rejectCallback = reject; - } ); - - return { - resolve: resolveCallback, - reject: rejectCallback, - promise: () => promise, - }; -} From 9a6e8c0066b9da38316cd279776ffef8e4967ba0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 09:15:07 -0400 Subject: [PATCH 02/10] refactor: Rename AJAX and api-fetch configuration utilities --- src/utils/ajax.js | 2 +- src/utils/editor-environment.js | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index 8d9bd76db..d608dcd89 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -10,7 +10,7 @@ import { warn, debug } from './logger'; * * @return {void} */ -export function initializeAjax() { +export function configureAjax() { window.wp = window.wp || {}; window.wp.ajax = window.wp.ajax || {}; window.wp.ajax.settings = window.wp.ajax.settings || {}; diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 3115f7c25..fbffaede4 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -9,7 +9,7 @@ import { } from './bridge'; import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; -import { initializeAjax } from './ajax'; +import { configureAjax } from './ajax'; import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; import { setLogLevel, error } from './logger'; @@ -32,7 +32,7 @@ export async function setUpEditorEnvironment() { initializeFetchInterceptor(); await configureLocale(); await initializeWordPressGlobals(); - await configureApiFetch(); + await configureNetworkUtils(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -90,12 +90,14 @@ async function initializeWordPressGlobals() { * Configure `api-fetch` middleware and settings. Lazy-loaded to ensure * WordPress globals are available before importing `api-fetch` and * referencing `window.wp.apiFetch`. + * + * Also, configure AJAX URL and token authentication. */ -async function configureApiFetch() { - const { configureApiFetch: _configureApiFetch } = await import( - './api-fetch' - ); - _configureApiFetch(); +async function configureNetworkUtils() { + configureAjax(); + + const { configureApiFetch } = await import( './api-fetch' ); + configureApiFetch(); } /** @@ -137,7 +139,6 @@ async function loadPluginsIfEnabled() { * @return {Promise} Promise that resolves when the editor is initialized */ async function initializeEditor( pluginLoadResult = {} ) { - initializeAjax(); const { initializeEditor: _initializeEditor } = await import( './editor' ); _initializeEditor( { allowedBlockTypes: pluginLoadResult.allowedBlockTypes, From fbe3f596d8ec26510580194155e03bc93a397da1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 14:25:04 -0400 Subject: [PATCH 03/10] fix: Configure AJAX after the library loads If we configure AJAX before loading the library, the configuration is overridden. --- src/utils/editor-environment.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index fbffaede4..5b7fcca18 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -32,7 +32,7 @@ export async function setUpEditorEnvironment() { initializeFetchInterceptor(); await configureLocale(); await initializeWordPressGlobals(); - await configureNetworkUtils(); + await configureApiFetch(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -90,14 +90,12 @@ async function initializeWordPressGlobals() { * Configure `api-fetch` middleware and settings. Lazy-loaded to ensure * WordPress globals are available before importing `api-fetch` and * referencing `window.wp.apiFetch`. - * - * Also, configure AJAX URL and token authentication. */ -async function configureNetworkUtils() { - configureAjax(); - - const { configureApiFetch } = await import( './api-fetch' ); - configureApiFetch(); +async function configureApiFetch() { + const { configureApiFetch: _configureApiFetch } = await import( + './api-fetch' + ); + _configureApiFetch(); } /** @@ -140,6 +138,8 @@ async function loadPluginsIfEnabled() { */ async function initializeEditor( pluginLoadResult = {} ) { const { initializeEditor: _initializeEditor } = await import( './editor' ); + configureAjax(); // Configure AJAX URL and token authentication + _initializeEditor( { allowedBlockTypes: pluginLoadResult.allowedBlockTypes, pluginLoadFailed: pluginLoadResult.pluginLoadFailed, From 2067796eb7bb7593882da8ea6e9d25caa5942f8d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 16:08:09 -0400 Subject: [PATCH 04/10] test: Fix test imports and assertions --- src/utils/editor-environment.test.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index 834fb13da..c91dfc8a1 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -16,7 +16,7 @@ import { import { loadEditorAssets } from './editor-loader.js'; import EditorLoadError from '../components/editor-load-error/index.jsx'; import { error } from './logger.js'; -import { initializeVideoPressAjaxBridge } from './videopress-bridge.js'; +import { configureAjax } from './ajax.js'; import { initializeWordPressGlobals } from './wordpress-globals.js'; import { configureLocale } from './localization.js'; import { configureApiFetch } from './api-fetch.js'; @@ -27,7 +27,7 @@ vi.mock( './bridge.js' ); vi.mock( './fetch-interceptor.js' ); vi.mock( './logger.js' ); vi.mock( './editor-styles.js' ); -vi.mock( './videopress-bridge.js' ); +vi.mock( './ajax.js' ); vi.mock( './wordpress-globals.js', () => ( { initializeWordPressGlobals: vi.fn(), @@ -63,7 +63,7 @@ describe( 'setUpEditorEnvironment', () => { initializeWordPressGlobals.mockImplementation( () => {} ); configureApiFetch.mockImplementation( () => {} ); initializeFetchInterceptor.mockImplementation( () => {} ); - initializeVideoPressAjaxBridge.mockImplementation( () => {} ); + configureAjax.mockImplementation( () => {} ); initializeEditor.mockImplementation( () => {} ); EditorLoadError.mockReturnValue( '
Error
' ); loadEditorAssets.mockResolvedValue( { @@ -96,8 +96,8 @@ describe( 'setUpEditorEnvironment', () => { callOrder.push( 'configureApiFetch' ); } ); - initializeVideoPressAjaxBridge.mockImplementation( () => { - callOrder.push( 'initializeVideoPress' ); + configureAjax.mockImplementation( () => { + callOrder.push( 'configureAjax' ); } ); initializeEditor.mockImplementation( () => { @@ -112,7 +112,7 @@ describe( 'setUpEditorEnvironment', () => { 'configureLocale', 'loadRemainingGlobals', 'configureApiFetch', - 'initializeVideoPress', + 'configureAjax', 'initializeEditor', ] ); } ); From 88abb5a5b721705f7eb79219012617483a3e9d15 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 22 Sep 2025 10:22:09 -0400 Subject: [PATCH 05/10] fix: Set the global WordPress admin AJAX URL This global is often used by WordPress Admin page scripts. --- src/utils/ajax.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index d608dcd89..e009c881e 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -26,6 +26,9 @@ function configureAjaxUrl( siteURL ) { return; } + // Global used within WordPress admin pages + window.ajaxurl = `${ siteURL }/wp-admin/admin-ajax.php`; + // Global used by WordPress' JavaScript API window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; debug( 'AJAX URL configured' ); From ac0ecd0086bba4230ddb49b8ed756a43820d9523 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 22 Sep 2025 10:44:00 -0400 Subject: [PATCH 06/10] test: Assert AJAX configuration --- src/utils/ajax.test.js | 491 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 src/utils/ajax.test.js diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js new file mode 100644 index 000000000..a9f988d58 --- /dev/null +++ b/src/utils/ajax.test.js @@ -0,0 +1,491 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { configureAjax } from './ajax'; +import * as bridge from './bridge'; +import * as logger from './logger'; + +vi.mock( './bridge' ); +vi.mock( './logger' ); + +describe( 'configureAjax', () => { + let originalWindow; + let mockJQueryAjaxSetup; + let originalWpAjaxSend; + let originalWpAjaxPost; + + beforeEach( () => { + vi.clearAllMocks(); + + // Store original window state + originalWindow = { + wp: global.window.wp, + ajaxurl: global.window.ajaxurl, + jQuery: global.window.jQuery, + }; + + // Reset window.wp + global.window.wp = undefined; + global.window.ajaxurl = undefined; + + // Mock jQuery + mockJQueryAjaxSetup = vi.fn(); + global.window.jQuery = { + ajaxSetup: mockJQueryAjaxSetup, + }; + + // Create mock functions for wp.ajax methods + originalWpAjaxSend = vi.fn( ( options ) => { + // Simulate calling beforeSend if it exists + if ( options?.beforeSend ) { + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + } + return Promise.resolve(); + } ); + + originalWpAjaxPost = vi.fn( ( options ) => { + // Simulate calling beforeSend if it exists + if ( options?.beforeSend ) { + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + } + return Promise.resolve(); + } ); + } ); + + afterEach( () => { + // Restore original window state + global.window.wp = originalWindow.wp; + global.window.ajaxurl = originalWindow.ajaxurl; + global.window.jQuery = originalWindow.jQuery; + } ); + + describe( 'URL configuration', () => { + it( 'should configure ajax URLs when siteURL is provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + } ); + + it( 'should log warning when siteURL is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should handle undefined siteURL', () => { + bridge.getGBKit.mockReturnValue( { + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should properly initialize window.wp.ajax hierarchy', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Ensure window.wp doesn't exist initially + expect( global.window.wp ).toBeUndefined(); + + configureAjax(); + + expect( global.window.wp ).toBeDefined(); + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + } ); + + describe( 'Auth configuration', () => { + beforeEach( () => { + // Setup wp.ajax with original methods + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + post: originalWpAjaxPost, + settings: {}, + }, + }; + } ); + + it( 'should configure jQuery ajax with auth header', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + expect( mockJQueryAjaxSetup ).toHaveBeenCalledWith( { + headers: { + Authorization: 'Bearer test-token', + }, + } ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should wrap wp.ajax.send with auth header', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer send-token', + } ); + + configureAjax(); + + // Call the wrapped send method + const options = { data: 'test' }; + await global.window.wp.ajax.send( options ); + + // Verify the original was called + expect( originalWpAjaxSend ).toHaveBeenCalled(); + + // Verify beforeSend was added + const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 0 ]; + expect( calledOptions.beforeSend ).toBeDefined(); + + // Verify auth header is set + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer send-token' + ); + } ); + + it( 'should wrap wp.ajax.post with auth header', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer post-token', + } ); + + configureAjax(); + + // Call the wrapped post method + const options = { action: 'test_action' }; + await global.window.wp.ajax.post( options ); + + // Verify the original was called + expect( originalWpAjaxPost ).toHaveBeenCalled(); + + // Verify beforeSend was added + const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 0 ]; + expect( calledOptions.beforeSend ).toBeDefined(); + + // Verify auth header is set + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer post-token' + ); + } ); + + it( 'should preserve original beforeSend in wp.ajax.send', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer preserve-token', + } ); + + configureAjax(); + + // Call with existing beforeSend + const originalBeforeSend = vi.fn(); + const options = { beforeSend: originalBeforeSend }; + await global.window.wp.ajax.send( options ); + + // Get the wrapped beforeSend + const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 0 ]; + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + + // Verify both auth header and original beforeSend were called + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer preserve-token' + ); + expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); + } ); + + it( 'should preserve original beforeSend in wp.ajax.post', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer preserve-post-token', + } ); + + configureAjax(); + + // Call with existing beforeSend + const originalBeforeSend = vi.fn(); + const options = { beforeSend: originalBeforeSend }; + await global.window.wp.ajax.post( options ); + + // Get the wrapped beforeSend + const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 0 ]; + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + + // Verify both auth header and original beforeSend were called + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer preserve-post-token' + ); + expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); + } ); + + it( 'should log warning when authHeader is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxSetup ).not.toHaveBeenCalled(); + } ); + + it( 'should handle undefined authHeader', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxSetup ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'Integration tests', () => { + it( 'should configure both URL and auth when both are provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer full-token', + } ); + + // Setup wp.ajax with methods + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + post: originalWpAjaxPost, + settings: {}, + }, + }; + + configureAjax(); + + // Check URL configuration + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + + // Check auth configuration + expect( mockJQueryAjaxSetup ).toHaveBeenCalledWith( { + headers: { + Authorization: 'Bearer full-token', + }, + } ); + + // Check debug logs + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle empty configuration object', () => { + bridge.getGBKit.mockReturnValue( {} ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + } ); + } ); + + describe( 'Edge cases', () => { + it( 'should handle missing jQuery gracefully', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-jquery', + } ); + + delete global.window.jQuery; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle undefined jQuery', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer undefined-jquery', + } ); + + global.window.jQuery = undefined; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle missing wp.ajax.send method', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-send', + } ); + + global.window.wp = { + ajax: { + post: originalWpAjaxPost, + settings: {}, + }, + }; + + expect( () => configureAjax() ).not.toThrow(); + + // Should still wrap post + expect( global.window.wp.ajax.post ).not.toBe( originalWpAjaxPost ); + } ); + + it( 'should handle missing wp.ajax.post method', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-post', + } ); + + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + settings: {}, + }, + }; + + expect( () => configureAjax() ).not.toThrow(); + + // Should still wrap send + expect( global.window.wp.ajax.send ).not.toBe( originalWpAjaxSend ); + } ); + + it( 'should handle missing wp.ajax entirely', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-ajax', + } ); + + global.window.wp = {}; + + expect( () => configureAjax() ).not.toThrow(); + + // Should create ajax object + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + + it( 'should work with window.wp already partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp object with other properties + global.window.wp = { + data: { someData: 'test' }, + }; + + configureAjax(); + + // Should preserve existing properties + expect( global.window.wp.data ).toEqual( { someData: 'test' } ); + + // Should add ajax properties + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should work when wp.ajax is partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp.ajax object without settings + global.window.wp = { + ajax: { + someMethod: vi.fn(), + }, + }; + + configureAjax(); + + // Should preserve existing methods + expect( global.window.wp.ajax.someMethod ).toBeDefined(); + + // Should add settings + expect( global.window.wp.ajax.settings ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + } ); +} ); From 20ec356650346a1f3d83bfc049a5675581c4c58b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 2 Oct 2025 14:54:19 -0400 Subject: [PATCH 07/10] feat: Allow configuring the Android asset loader domain Useful when needing to allow CORS for specific domains. --- .../org/wordpress/gutenberg/GutenbergView.kt | 25 +++++++++++++------ .../gutenberg/model/EditorConfiguration.kt | 9 ++++++- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 348c32365..e92318aee 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -42,7 +42,8 @@ import org.wordpress.gutenberg.model.GBKitGlobal import org.wordpress.gutenberg.services.EditorService import java.util.Locale -const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" +const val DEFAULT_ASSET_DOMAIN = "appassets.androidplatform.net" +const val ASSET_PATH_INDEX = "/assets/index.html" /** * A WebView-based Gutenberg block editor for Android. @@ -85,9 +86,8 @@ const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" class GutenbergView : WebView { private var isEditorLoaded = false private var didFireEditorLoaded = false - private var assetLoader = WebViewAssetLoader.Builder() - .addPathHandler("/assets/", AssetsPathHandler(this.context)) - .build() + private lateinit var assetLoader: WebViewAssetLoader + private lateinit var assetDomain: String private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -235,7 +235,7 @@ class GutenbergView : WebView { ): WebResourceResponse? { if (request.url == null) { return super.shouldInterceptRequest(view, request) - } else if (request.url.host?.contains("appassets.androidplatform.net") == true) { + } else if (request.url.host == assetDomain) { return assetLoader.shouldInterceptRequest(request.url) } else if (requestInterceptor.canIntercept(request)) { return requestInterceptor.handleRequest(request) @@ -268,7 +268,7 @@ class GutenbergView : WebView { } // Allow asset URLs - if (url.host == Uri.parse(ASSET_URL).host) { + if (url.host == assetDomain) { return false } @@ -386,6 +386,15 @@ class GutenbergView : WebView { private fun loadEditor(dependencies: EditorDependencies) { this.dependencies = dependencies + // Set up asset loader domain + assetDomain = configuration.assetLoaderDomain ?: DEFAULT_ASSET_DOMAIN + + // Initialize asset loader with configured domain + assetLoader = WebViewAssetLoader.Builder() + .setDomain(assetDomain) + .addPathHandler("/assets/", AssetsPathHandler(this.context)) + .build() + // Set up asset caching requestInterceptor = CachedAssetRequestInterceptor( dependencies.assetBundle, @@ -398,13 +407,13 @@ class GutenbergView : WebView { initializeWebView() val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { - ASSET_URL + "https://$assetDomain$ASSET_PATH_INDEX" } WebStorage.getInstance().deleteAllData() this.clearCache(true) // All cookies are third-party cookies because the root of this document - // lives under `https://appassets.androidplatform.net` + // lives under the configured asset domain (e.g., `https://appassets.androidplatform.net`) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) // Erase all local cookies before loading the URL – we don't want to persist diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 2e44cbaef..d36dbb171 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -29,6 +29,7 @@ data class EditorConfiguration( val editorAssetsEndpoint: String? = null, val enableNetworkLogging: Boolean = false, var enableOfflineMode: Boolean = false, + val assetLoaderDomain: String? = null ): Parcelable { /** @@ -73,6 +74,7 @@ data class EditorConfiguration( private var editorAssetsEndpoint: String? = null private var enableNetworkLogging: Boolean = false private var enableOfflineMode: Boolean = false + private var assetLoaderDomain: String? = null fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } @@ -95,6 +97,7 @@ data class EditorConfiguration( fun setEditorAssetsEndpoint(editorAssetsEndpoint: String?) = apply { this.editorAssetsEndpoint = editorAssetsEndpoint } fun setEnableNetworkLogging(enableNetworkLogging: Boolean) = apply { this.enableNetworkLogging = enableNetworkLogging } fun setEnableOfflineMode(enableOfflineMode: Boolean) = apply { this.enableOfflineMode = enableOfflineMode } + fun setAssetLoaderDomain(assetLoaderDomain: String?) = apply { this.assetLoaderDomain = assetLoaderDomain } fun build(): EditorConfiguration = EditorConfiguration( title = title, @@ -117,7 +120,8 @@ data class EditorConfiguration( cachedAssetHosts = cachedAssetHosts, editorAssetsEndpoint = editorAssetsEndpoint, enableNetworkLogging = enableNetworkLogging, - enableOfflineMode = enableOfflineMode + enableOfflineMode = enableOfflineMode, + assetLoaderDomain = assetLoaderDomain ) } @@ -145,6 +149,7 @@ data class EditorConfiguration( .setEditorAssetsEndpoint(editorAssetsEndpoint) .setEnableNetworkLogging(enableNetworkLogging) .setEnableOfflineMode(enableOfflineMode) + .setAssetLoaderDomain(assetLoaderDomain) override fun equals(other: Any?): Boolean { if (this === other) return true @@ -173,6 +178,7 @@ data class EditorConfiguration( if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false if (enableNetworkLogging != other.enableNetworkLogging) return false if (enableOfflineMode != other.enableOfflineMode) return false + if (assetLoaderDomain != other.assetLoaderDomain) return false if (siteId != other.siteId) return false return true @@ -200,6 +206,7 @@ data class EditorConfiguration( result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0) result = 31 * result + enableNetworkLogging.hashCode() result = 31 * result + enableOfflineMode.hashCode() + result = 31 * result + (assetLoaderDomain?.hashCode() ?: 0) result = 31 * result + siteId.hashCode() return result } From a17a7fb4c08a4310e3ea48e36ad38c1fa3da2b70 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 13 Jan 2026 14:30:47 -0500 Subject: [PATCH 08/10] docs: Note AJAX support requirements --- docs/integration.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/integration.md b/docs/integration.md index bd606add6..973c39c73 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -291,3 +291,43 @@ val configuration = EditorConfiguration.builder() .setEditorSettings(editorSettingsJSON) .build() ``` + +### AJAX Support + +Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for functionality like form submissions. GutenbergKit supports AJAX requests when properly configured. + +**Requirements:** + +1. **Production bundle required**: AJAX requests fail with CORS errors when using the development server because the editor runs on `localhost` while AJAX requests target your WordPress site. You must use a production bundle built with `make build`. + +2. **Configure `siteURL`**: The `siteURL` configuration option must be set to your WordPress site URL. This is used to construct the AJAX endpoint (`{siteURL}/wp-admin/admin-ajax.php`). + +3. **Set authentication header**: The `authHeader` configuration must be set. GutenbergKit injects this header into all AJAX requests since the WebView lacks WordPress authentication cookies. + +4. **Android: Configure `assetLoaderDomain`**: On Android, you must set the `assetLoaderDomain` to a domain that your WordPress site/plugin allows. This is because Android's WebViewAssetLoader serves the editor from a configurable domain, and AJAX requests must pass CORS validation on your server. + + For example, the Jetpack mobile plugin allows requests from `android-app-assets.jetpack.com`: + +```swift +// iOS - siteURL and authHeader are required +let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json")! +) + .setAuthHeader("Bearer your-token") + .build() +``` + +```kotlin +// Android - assetLoaderDomain is also required for AJAX +val configuration = EditorConfiguration.builder() + .setPostType("post") + .setSiteURL("https://example.com") + .setSiteApiRoot("https://example.com/wp-json") + .setAuthHeader("Bearer your-token") + .setAssetLoaderDomain("android-app-assets.jetpack.com") // Must be allowed by your WordPress site + .build() +``` + +**Server-side CORS configuration**: Your WordPress site must include the `assetLoaderDomain` in its CORS allowed origins. This is typically handled by your WordPress plugin (e.g., Jetpack) that integrates with the mobile app. From 64d4eb120830d455d759856a89737c59052a913d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 13 Jan 2026 14:34:44 -0500 Subject: [PATCH 09/10] docs: Note AJAX CORS errors in troubleshooting documentation --- docs/code/troubleshooting.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/code/troubleshooting.md b/docs/code/troubleshooting.md index 300ac055f..cf0acafe3 100644 --- a/docs/code/troubleshooting.md +++ b/docs/code/troubleshooting.md @@ -28,3 +28,13 @@ The file does not exist at "[path]" which is in the optimize deps directory. The - Deleting the `node_modules/.vite` directory (or `node_modules` entirely) and restarting the development server via `make dev-server`. You may also need to clear your browser cache to ensure no stale files are used. + +## AJAX requests fail with CORS errors + +**Error:** `Access to XMLHttpRequest at 'https://example.com/wp-admin/admin-ajax.php' from origin 'http://localhost:5173' has been blocked by CORS policy` + +This error occurs when the editor makes AJAX requests (e.g., from blocks that use `admin-ajax.php`) while running on the development server. The browser blocks these cross-origin requests because the editor runs on `localhost` while AJAX targets your WordPress site. + +**Solution:** AJAX functionality requires a production bundle. Build the editor assets with `make build` and test AJAX features using the demo apps without using the `GUTENBERG_EDITOR_URL` environment variable. + +For Android, you must also configure `assetLoaderDomain` to a domain allowed by your WordPress site's CORS policy. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details. From ea73b3c2d3c18157664c4a22410f949db8414ea3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Jan 2026 15:03:51 -0500 Subject: [PATCH 10/10] fix: Add type checks before wrapping wp.ajax methods (#282) Address PR feedback about potential race condition. The code now checks if `window.wp.ajax.send` and `window.wp.ajax.post` are functions before wrapping them. This prevents TypeError when calling the wrapped function if the original method was undefined during configuration. Update tests to verify that missing methods remain undefined rather than being wrapped with an undefined reference. Co-authored-by: Claude --- src/utils/ajax.js | 46 +++++++++++++++++++++++------------------- src/utils/ajax.test.js | 6 ++++++ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index e009c881e..ed4df197b 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -46,35 +46,39 @@ function configureAjaxAuth( authHeader ) { }, } ); - const originalSend = window.wp.ajax.send; - window.wp.ajax.send = function ( options ) { - const originalBeforeSend = options.beforeSend; + if ( typeof window.wp.ajax.send === 'function' ) { + const originalSend = window.wp.ajax.send; + window.wp.ajax.send = function ( options ) { + const originalBeforeSend = options.beforeSend; - options.beforeSend = function ( xhr ) { - xhr.setRequestHeader( 'Authorization', authHeader ); + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); - if ( typeof originalBeforeSend === 'function' ) { - originalBeforeSend( xhr ); - } + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + + return originalSend.call( this, options ); }; + } - return originalSend.call( this, options ); - }; + if ( typeof window.wp.ajax.post === 'function' ) { + const originalPost = window.wp.ajax.post; + window.wp.ajax.post = function ( options ) { + const originalBeforeSend = options.beforeSend; - const originalPost = window.wp.ajax.post; - window.wp.ajax.post = function ( options ) { - const originalBeforeSend = options.beforeSend; + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); - options.beforeSend = function ( xhr ) { - xhr.setRequestHeader( 'Authorization', authHeader ); + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; - if ( typeof originalBeforeSend === 'function' ) { - originalBeforeSend( xhr ); - } + return originalPost.call( this, options ); }; - - return originalPost.call( this, options ); - }; + } debug( 'AJAX auth configured' ); } diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index a9f988d58..28cf84cd4 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -402,6 +402,9 @@ describe( 'configureAjax', () => { expect( () => configureAjax() ).not.toThrow(); + // Should not wrap send (it doesn't exist) + expect( global.window.wp.ajax.send ).toBeUndefined(); + // Should still wrap post expect( global.window.wp.ajax.post ).not.toBe( originalWpAjaxPost ); } ); @@ -421,6 +424,9 @@ describe( 'configureAjax', () => { expect( () => configureAjax() ).not.toThrow(); + // Should not wrap post (it doesn't exist) + expect( global.window.wp.ajax.post ).toBeUndefined(); + // Should still wrap send expect( global.window.wp.ajax.send ).not.toBe( originalWpAjaxSend ); } );