From d1ba186d936fdfd0a5d17a79b9567e91d49d6030 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 17 Sep 2025 10:40:04 -0400 Subject: [PATCH 01/11] 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 00000000..8d9bd76d --- /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 4470638a..3115f7c2 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 7bed2ee2..00000000 --- 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 a6ff6521b75b1ecf9472479f2e87c9d965b25d26 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 09:15:07 -0400 Subject: [PATCH 02/11] 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 8d9bd76d..d608dcd8 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 3115f7c2..fbffaede 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 f4ecea88584431e25acdf2d0a663ad83810241ba Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 14:25:04 -0400 Subject: [PATCH 03/11] 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 fbffaede..5b7fcca1 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 7013fe8bb1ec44d90534a38c58040bf72a5d00b5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 16:08:09 -0400 Subject: [PATCH 04/11] 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 834fb13d..c91dfc8a 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 9c10108e91a925f6aba69e132e0d9b58f519d491 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 22 Sep 2025 10:22:09 -0400 Subject: [PATCH 05/11] 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 d608dcd8..e009c881 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 88974ad19d3e1937b8c63ccad22b7a38ce8f039c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 22 Sep 2025 10:44:00 -0400 Subject: [PATCH 06/11] 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 00000000..a9f988d5 --- /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 e9082c90d8b3d641a46586466cf53a5e37dfbc9f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 22 Sep 2025 15:01:30 -0400 Subject: [PATCH 07/11] fix: Configure `siteURL` for Android --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 1 + 1 file changed, 1 insertion(+) 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 6ea3abea..fd6923bb 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -302,6 +302,7 @@ class GutenbergView : WebView { val gbKitConfig = """ window.GBKit = { + "siteURL": "${configuration.siteURL}", "siteApiRoot": "${configuration.siteApiRoot}", "siteApiNamespace": ${configuration.siteApiNamespace.joinToString(",", "[", "]") { "\"$it\"" }}, "namespaceExcludedPaths": ${configuration.namespaceExcludedPaths.joinToString(",", "[", "]") { "\"$it\"" }}, From f912eefd71ada853b2617135025f667d6dfb8e60 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 2 Oct 2025 14:54:19 -0400 Subject: [PATCH 08/11] feat: Allow configuring the Android asset loader domain Useful when needing to allow CORS for specific domains. --- .../gutenberg/EditorConfiguration.kt | 10 ++++++-- .../org/wordpress/gutenberg/GutenbergView.kt | 25 +++++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt index 83403f4e..027d2d85 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt @@ -23,7 +23,8 @@ open class EditorConfiguration constructor( val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, - val enableNetworkLogging: Boolean = false + val enableNetworkLogging: Boolean = false, + val assetLoaderDomain: String? = null ): Parcelable { companion object { @JvmStatic @@ -50,6 +51,7 @@ open class EditorConfiguration constructor( private var cachedAssetHosts: Set = emptySet() private var editorAssetsEndpoint: String? = null private var enableNetworkLogging: Boolean = false + private var assetLoaderDomain: String? = null fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } @@ -70,6 +72,7 @@ open class EditorConfiguration constructor( fun setCachedAssetHosts(cachedAssetHosts: Set) = apply { this.cachedAssetHosts = cachedAssetHosts } fun setEditorAssetsEndpoint(editorAssetsEndpoint: String?) = apply { this.editorAssetsEndpoint = editorAssetsEndpoint } fun setEnableNetworkLogging(enableNetworkLogging: Boolean) = apply { this.enableNetworkLogging = enableNetworkLogging } + fun setAssetLoaderDomain(assetLoaderDomain: String?) = apply { this.assetLoaderDomain = assetLoaderDomain } fun build(): EditorConfiguration = EditorConfiguration( title = title, @@ -90,7 +93,8 @@ open class EditorConfiguration constructor( enableAssetCaching = enableAssetCaching, cachedAssetHosts = cachedAssetHosts, editorAssetsEndpoint = editorAssetsEndpoint, - enableNetworkLogging = enableNetworkLogging + enableNetworkLogging = enableNetworkLogging, + assetLoaderDomain = assetLoaderDomain ) } @@ -119,6 +123,7 @@ open class EditorConfiguration constructor( if (cachedAssetHosts != other.cachedAssetHosts) return false if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false if (enableNetworkLogging != other.enableNetworkLogging) return false + if (assetLoaderDomain != other.assetLoaderDomain) return false return true } @@ -143,6 +148,7 @@ open class EditorConfiguration constructor( result = 31 * result + cachedAssetHosts.hashCode() result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0) result = 31 * result + enableNetworkLogging.hashCode() + result = 31 * result + (assetLoaderDomain?.hashCode() ?: 0) return result } } 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 fd6923bb..fa95c792 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -30,14 +30,14 @@ import org.json.JSONException import org.json.JSONObject 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" 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 var configuration: EditorConfiguration = EditorConfiguration.builder().build() private val handler = Handler(Looper.getMainLooper()) @@ -149,7 +149,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) @@ -182,7 +182,7 @@ class GutenbergView : WebView { } // Allow asset URLs - if (url.host == Uri.parse(ASSET_URL).host) { + if (url.host == assetDomain) { return false } @@ -258,6 +258,15 @@ class GutenbergView : WebView { fun start(configuration: EditorConfiguration) { this.configuration = configuration + // 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 if enabled if (configuration.enableAssetCaching) { val library = EditorAssetsLibrary(context, configuration) @@ -273,13 +282,13 @@ class GutenbergView : WebView { val editorUrl = if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { BuildConfig.GUTENBERG_EDITOR_URL } else { - 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 From 3c57aa203f8ecfd1a26587ce2ccb223ff2240a95 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 13 Jan 2026 14:30:47 -0500 Subject: [PATCH 09/11] 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 708aca17..5c064697 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 bbaf9c41a4e82514453fc0a398a9521caf8a99b6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 13 Jan 2026 14:34:44 -0500 Subject: [PATCH 10/11] 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 300ac055..cf0acafe 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 394bce29fae82dea773160d6f05a2ea1d11db3d5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Jan 2026 15:03:51 -0500 Subject: [PATCH 11/11] 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 e009c881..ed4df197 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 a9f988d5..28cf84cd 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 ); } );