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 6ea3abea..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 @@ -302,6 +311,7 @@ class GutenbergView : WebView { val gbKitConfig = """ window.GBKit = { + "siteURL": "${configuration.siteURL}", "siteApiRoot": "${configuration.siteApiRoot}", "siteApiNamespace": ${configuration.siteApiNamespace.joinToString(",", "[", "]") { "\"$it\"" }}, "namespaceExcludedPaths": ${configuration.namespaceExcludedPaths.joinToString(",", "[", "]") { "\"$it\"" }}, 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. 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. diff --git a/src/utils/ajax.js b/src/utils/ajax.js new file mode 100644 index 00000000..ed4df197 --- /dev/null +++ b/src/utils/ajax.js @@ -0,0 +1,84 @@ +/** + * 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 configureAjax() { + 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; + } + + // 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' ); +} + +function configureAjaxAuth( authHeader ) { + if ( ! authHeader ) { + warn( 'Unable to configure AJAX auth without authHeader' ); + return; + } + + window.jQuery?.ajaxSetup( { + headers: { + Authorization: authHeader, + }, + } ); + + 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 ); + + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + + 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; + + 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/ajax.test.js b/src/utils/ajax.test.js new file mode 100644 index 00000000..28cf84cd --- /dev/null +++ b/src/utils/ajax.test.js @@ -0,0 +1,497 @@ +/** + * 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 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 ); + } ); + + 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 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 ); + } ); + + 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' + ); + } ); + } ); +} ); diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 4470638a..5b7fcca1 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 { configureAjax } 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 ) { @@ -139,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, 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', ] ); } ); 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, - }; -}