From 1b2a101f12c1d0254c4576ce65d987683728618e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:25:30 -0500 Subject: [PATCH 01/24] task: Add `onNetworkRequest` bridge utility --- src/utils/bridge.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 42e85088..e7a481ee 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -172,6 +172,25 @@ export function onModalDialogClosed( dialogType ) { dispatchToBridge( 'onModalDialogClosed', { dialogType } ); } +/** + * Notifies the native host about a network request and its response. + * + * @param {Object} requestData The network request data. + * @param {string} requestData.url The request URL. + * @param {string} requestData.method The HTTP method (GET, POST, etc.). + * @param {Object|null} requestData.requestHeaders The request headers object. + * @param {string|null} requestData.requestBody The request body. + * @param {number} requestData.status The HTTP response status code. + * @param {Object|null} requestData.responseHeaders The response headers object. + * @param {string|null} requestData.responseBody The response body. + * @param {number} requestData.duration The request duration in milliseconds. + * + * @return {void} + */ +export function onNetworkRequest( requestData ) { + dispatchToBridge( 'onNetworkRequest', requestData ); +} + /** * @typedef GBKitConfig * From 16a8d2132441b2df4b646a86489af8e94c5db4cd Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:26:04 -0500 Subject: [PATCH 02/24] docs: Direct Claude to format and lint code --- CLAUDE.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 713490f7..98aeaed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,12 +186,19 @@ The project uses a custom logger utility (`src/utils/logger.js`) instead of dire Note: Console logs should be used sparingly. For verbose or development-specific logging, prefer the `debug()` function which can be controlled via log levels. -Always run these commands before committing: +### Pre-Commit Checklist -```bash -# Lint JavaScript code -make lint-js +**IMPORTANT**: Always run these commands after making code changes and before presenting work for review/commit: +```bash # Format JavaScript code make format + +# Auto-fix linting errors +make lint-js-fix + +# Verify linting passes +make lint-js ``` + +These commands ensure code quality and prevent lint errors from blocking commits. From 878883de405cb9cdba1d3a5297788c705e554883 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:33:06 -0500 Subject: [PATCH 03/24] task: Add fetch override utility --- src/utils/fetch-interceptor.js | 201 +++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/utils/fetch-interceptor.js diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js new file mode 100644 index 00000000..c78d60fa --- /dev/null +++ b/src/utils/fetch-interceptor.js @@ -0,0 +1,201 @@ +/** + * Internal dependencies + */ +import { onNetworkRequest, getGBKit } from './bridge'; +import { debug } from './logger'; + +/** + * Serializes Headers object to a plain object. + * + * @param {Headers} headers The Headers object to serialize. + * + * @return {Object} Plain object representation of headers. + */ +function serializeHeaders( headers ) { + const result = {}; + if ( headers && typeof headers.forEach === 'function' ) { + headers.forEach( ( value, key ) => { + result[ key ] = value; + } ); + } + return result; +} + +/** + * Reads and serializes request/response body. + * Handles text, JSON, and binary data. + * + * @param {Response|Request} source The Response or Request object. + * + * @return {Promise} The serialized body or null. + */ +async function serializeBody( source ) { + try { + const contentType = source.headers.get( 'content-type' ) || ''; + + // Handle JSON + if ( contentType.includes( 'application/json' ) ) { + const text = await source.text(); + // Validate it's actually JSON + try { + JSON.parse( text ); + return text; + } catch ( e ) { + return text; + } + } + + // Handle text-based content + if ( + contentType.includes( 'text/' ) || + contentType.includes( 'application/javascript' ) || + contentType.includes( 'application/xml' ) + ) { + return await source.text(); + } + + // For binary/blob, just return size info + if ( source.blob ) { + const blob = await source.blob(); + return `[Binary data: ${ blob.size } bytes]`; + } + + // Fallback to text + return await source.text(); + } catch ( error ) { + return `[Error reading body: ${ error.message }]`; + } +} + +/** + * Extracts request details from fetch arguments. + * + * @param {string|Request} input The fetch input (URL or Request object). + * @param {Object} init The fetch init options. + * + * @return {Object} Request details object. + */ +function extractRequestDetails( input, init = {} ) { + let url; + let method = 'GET'; + let headers = {}; + + if ( typeof input === 'string' ) { + url = input; + method = init.method || 'GET'; + headers = init.headers || {}; + } else if ( input instanceof Request ) { + url = input.url; + method = input.method; + headers = input.headers; + } + + return { + url, + method: method.toUpperCase(), + headers, + }; +} + +/** + * Initializes the global fetch interceptor. + * Wraps window.fetch to log all network requests and responses. + * + * @return {void} + */ +export function initializeFetchInterceptor() { + // Don't initialize if already done + if ( window.__fetchInterceptorInitialized ) { + return; + } + + const originalFetch = window.fetch; + + window.fetch = async function ( input, init ) { + const config = getGBKit(); + + // Check if network logging is enabled + if ( ! config.enableNetworkLogging ) { + return originalFetch( input, init ); + } + + const startTime = performance.now(); + const requestDetails = extractRequestDetails( input, init ); + + let requestBody = null; + let clonedRequest = null; + + // Try to read request body if present + try { + if ( init?.body ) { + // Body is provided in init options + if ( typeof init.body === 'string' ) { + requestBody = init.body; + } else { + requestBody = String( init.body ); + } + } else if ( input instanceof Request ) { + // Body might be in Request object - clone to read it + clonedRequest = input.clone(); + requestBody = await serializeBody( clonedRequest ); + } + } catch ( error ) { + debug( `Error reading request body: ${ error.message }` ); + requestBody = `[Error reading request body: ${ error.message }]`; + } + + let response; + let responseStatus; + let responseHeaders = {}; + let responseBody = null; + + try { + // Call original fetch + response = await originalFetch( input, init ); + + // Clone response to read body without consuming it + const responseClone = response.clone(); + responseStatus = response.status; + responseHeaders = serializeHeaders( response.headers ); + + // Read response body from clone + responseBody = await serializeBody( responseClone ); + + // Calculate duration and log + const duration = Math.round( performance.now() - startTime ); + + onNetworkRequest( { + url: requestDetails.url, + method: requestDetails.method, + requestHeaders: serializeHeaders( requestDetails.headers ), + requestBody, + status: responseStatus, + responseHeaders, + responseBody, + duration, + } ); + + return response; + } catch ( error ) { + // Log failed request + const duration = Math.round( performance.now() - startTime ); + + onNetworkRequest( { + url: requestDetails.url, + method: requestDetails.method, + requestHeaders: serializeHeaders( requestDetails.headers ), + requestBody, + status: 0, + responseHeaders: {}, + responseBody: `[Network error: ${ error.message }]`, + duration, + } ); + + // Re-throw the error + throw error; + } + }; + + window.__fetchInterceptorInitialized = true; + debug( 'Fetch interceptor initialized' ); +} From 40957458667ae48395bcc62a8ad2519ef7d4b510 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:34:20 -0500 Subject: [PATCH 04/24] refactor: Avoid overriding fetch if network logging is disabled --- src/utils/fetch-interceptor.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js index c78d60fa..164201c9 100644 --- a/src/utils/fetch-interceptor.js +++ b/src/utils/fetch-interceptor.js @@ -100,6 +100,7 @@ function extractRequestDetails( input, init = {} ) { /** * Initializes the global fetch interceptor. * Wraps window.fetch to log all network requests and responses. + * Only overrides window.fetch if network logging is enabled in config. * * @return {void} */ @@ -109,16 +110,17 @@ export function initializeFetchInterceptor() { return; } - const originalFetch = window.fetch; + const config = getGBKit(); - window.fetch = async function ( input, init ) { - const config = getGBKit(); + // Only override window.fetch if network logging is enabled + if ( ! config.enableNetworkLogging ) { + debug( 'Network logging disabled, fetch interceptor not initialized' ); + return; + } - // Check if network logging is enabled - if ( ! config.enableNetworkLogging ) { - return originalFetch( input, init ); - } + const originalFetch = window.fetch; + window.fetch = async function ( input, init ) { const startTime = performance.now(); const requestDetails = extractRequestDetails( input, init ); From b31ba8807a73fc6b4b38f7fc1a9edbae62d7ebd6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:37:54 -0500 Subject: [PATCH 05/24] refactor: Sort functions by usage occurrence --- src/utils/fetch-interceptor.js | 186 ++++++++++++++++----------------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js index 164201c9..7ac42c76 100644 --- a/src/utils/fetch-interceptor.js +++ b/src/utils/fetch-interceptor.js @@ -4,99 +4,6 @@ import { onNetworkRequest, getGBKit } from './bridge'; import { debug } from './logger'; -/** - * Serializes Headers object to a plain object. - * - * @param {Headers} headers The Headers object to serialize. - * - * @return {Object} Plain object representation of headers. - */ -function serializeHeaders( headers ) { - const result = {}; - if ( headers && typeof headers.forEach === 'function' ) { - headers.forEach( ( value, key ) => { - result[ key ] = value; - } ); - } - return result; -} - -/** - * Reads and serializes request/response body. - * Handles text, JSON, and binary data. - * - * @param {Response|Request} source The Response or Request object. - * - * @return {Promise} The serialized body or null. - */ -async function serializeBody( source ) { - try { - const contentType = source.headers.get( 'content-type' ) || ''; - - // Handle JSON - if ( contentType.includes( 'application/json' ) ) { - const text = await source.text(); - // Validate it's actually JSON - try { - JSON.parse( text ); - return text; - } catch ( e ) { - return text; - } - } - - // Handle text-based content - if ( - contentType.includes( 'text/' ) || - contentType.includes( 'application/javascript' ) || - contentType.includes( 'application/xml' ) - ) { - return await source.text(); - } - - // For binary/blob, just return size info - if ( source.blob ) { - const blob = await source.blob(); - return `[Binary data: ${ blob.size } bytes]`; - } - - // Fallback to text - return await source.text(); - } catch ( error ) { - return `[Error reading body: ${ error.message }]`; - } -} - -/** - * Extracts request details from fetch arguments. - * - * @param {string|Request} input The fetch input (URL or Request object). - * @param {Object} init The fetch init options. - * - * @return {Object} Request details object. - */ -function extractRequestDetails( input, init = {} ) { - let url; - let method = 'GET'; - let headers = {}; - - if ( typeof input === 'string' ) { - url = input; - method = init.method || 'GET'; - headers = init.headers || {}; - } else if ( input instanceof Request ) { - url = input.url; - method = input.method; - headers = input.headers; - } - - return { - url, - method: method.toUpperCase(), - headers, - }; -} - /** * Initializes the global fetch interceptor. * Wraps window.fetch to log all network requests and responses. @@ -201,3 +108,96 @@ export function initializeFetchInterceptor() { window.__fetchInterceptorInitialized = true; debug( 'Fetch interceptor initialized' ); } + +/** + * Extracts request details from fetch arguments. + * + * @param {string|Request} input The fetch input (URL or Request object). + * @param {Object} init The fetch init options. + * + * @return {Object} Request details object. + */ +function extractRequestDetails( input, init = {} ) { + let url; + let method = 'GET'; + let headers = {}; + + if ( typeof input === 'string' ) { + url = input; + method = init.method || 'GET'; + headers = init.headers || {}; + } else if ( input instanceof Request ) { + url = input.url; + method = input.method; + headers = input.headers; + } + + return { + url, + method: method.toUpperCase(), + headers, + }; +} + +/** + * Reads and serializes request/response body. + * Handles text, JSON, and binary data. + * + * @param {Response|Request} source The Response or Request object. + * + * @return {Promise} The serialized body or null. + */ +async function serializeBody( source ) { + try { + const contentType = source.headers.get( 'content-type' ) || ''; + + // Handle JSON + if ( contentType.includes( 'application/json' ) ) { + const text = await source.text(); + // Validate it's actually JSON + try { + JSON.parse( text ); + return text; + } catch ( e ) { + return text; + } + } + + // Handle text-based content + if ( + contentType.includes( 'text/' ) || + contentType.includes( 'application/javascript' ) || + contentType.includes( 'application/xml' ) + ) { + return await source.text(); + } + + // For binary/blob, just return size info + if ( source.blob ) { + const blob = await source.blob(); + return `[Binary data: ${ blob.size } bytes]`; + } + + // Fallback to text + return await source.text(); + } catch ( error ) { + return `[Error reading body: ${ error.message }]`; + } +} + +/** + * Serializes Headers object to a plain object. + * + * @param {Headers} headers The Headers object to serialize. + * + * @return {Object} Plain object representation of headers. + */ +function serializeHeaders( headers ) { + const result = {}; + if ( headers && typeof headers.forEach === 'function' ) { + headers.forEach( ( value, key ) => { + result[ key ] = value; + } ); + } + return result; +} From 1042b421effa693817800cb0a16a7465dac0711a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:38:12 -0500 Subject: [PATCH 06/24] docs: Direct Claude to order functions by usage occurrence Make reading files easier, encountering high-level logic first before the implementation details of helper utilities. --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 98aeaed0..dbb073d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,6 +173,16 @@ The project follows WordPress coding standards for JavaScript: - **ESLint**: Uses `@wordpress/eslint-plugin/recommended` configuration - **Prettier**: Uses `@wordpress/prettier-config` for code formatting +### Function Ordering Convention + +Functions in this project are ordered by usage/call order rather than alphabetically: + +- **Main/exported functions first**: The primary exported function appears at the top of the file +- **Helper functions follow in call order**: Helper functions are ordered based on when they are first called in the main function +- **Example**: If `mainFunction()` calls `helperA()` then `helperB()`, the file order should be: `mainFunction`, `helperA`, `helperB` + +This ordering makes code easier to read top-to-bottom, as you encounter function definitions before needing to understand their implementation details. + ### Logging Guidelines The project uses a custom logger utility (`src/utils/logger.js`) instead of direct `console` methods: From 48cf4bca5881b07693f01defe0918656a814cb34 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:41:41 -0500 Subject: [PATCH 07/24] task: Initialize fetch interceptor --- src/utils/editor-environment.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 389fc64f..d97605b3 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -5,6 +5,7 @@ import { awaitGBKitGlobal, editorLoaded, getGBKit } from './bridge'; import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; import { initializeVideoPressAjaxBridge } from './videopress-bridge'; +import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; import { error } from './logger'; import { setUpGlobalErrorHandlers } from './global-error-handler'; @@ -20,6 +21,7 @@ import './editor-styles'; export async function setUpEditorEnvironment() { try { setUpGlobalErrorHandlers(); + initializeFetchInterceptor(); setBodyClasses(); await awaitGBKitGlobal(); await configureLocale(); From 94408e0fc123b27c72022aa9c62467cb80b85315 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:47:29 -0500 Subject: [PATCH 08/24] task: Add network logging configuration option --- .../gutenberg/EditorConfiguration.kt | 10 ++++++++-- .../org/wordpress/gutenberg/GutenbergView.kt | 1 + .../Sources/EditorConfiguration.swift | 20 ++++++++++++++++--- .../Sources/EditorViewController.swift | 3 ++- src/utils/bridge.js | 1 + 5 files changed, 29 insertions(+), 6 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 be3c319b..83403f4e 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt @@ -22,7 +22,8 @@ open class EditorConfiguration constructor( val cookies: Map, val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), - val editorAssetsEndpoint: String? = null + val editorAssetsEndpoint: String? = null, + val enableNetworkLogging: Boolean = false ): Parcelable { companion object { @JvmStatic @@ -48,6 +49,7 @@ open class EditorConfiguration constructor( private var enableAssetCaching: Boolean = false private var cachedAssetHosts: Set = emptySet() private var editorAssetsEndpoint: String? = null + private var enableNetworkLogging: Boolean = false fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } @@ -67,6 +69,7 @@ open class EditorConfiguration constructor( fun setEnableAssetCaching(enableAssetCaching: Boolean) = apply { this.enableAssetCaching = enableAssetCaching } 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 build(): EditorConfiguration = EditorConfiguration( title = title, @@ -86,7 +89,8 @@ open class EditorConfiguration constructor( cookies = cookies, enableAssetCaching = enableAssetCaching, cachedAssetHosts = cachedAssetHosts, - editorAssetsEndpoint = editorAssetsEndpoint + editorAssetsEndpoint = editorAssetsEndpoint, + enableNetworkLogging = enableNetworkLogging ) } @@ -114,6 +118,7 @@ open class EditorConfiguration constructor( if (enableAssetCaching != other.enableAssetCaching) return false if (cachedAssetHosts != other.cachedAssetHosts) return false if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false + if (enableNetworkLogging != other.enableNetworkLogging) return false return true } @@ -137,6 +142,7 @@ open class EditorConfiguration constructor( result = 31 * result + enableAssetCaching.hashCode() result = 31 * result + cachedAssetHosts.hashCode() result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0) + result = 31 * result + enableNetworkLogging.hashCode() 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 96d52289..0662065b 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -307,6 +307,7 @@ class GutenbergView : WebView { "editorSettings": $editorSettings, "locale": "${configuration.locale}", ${if (configuration.editorAssetsEndpoint != null) "\"editorAssetsEndpoint\": \"${configuration.editorAssetsEndpoint}\"," else ""} + "enableNetworkLogging": ${configuration.enableNetworkLogging}, "post": { "id": ${configuration.postId ?: -1}, "title": "$escapedTitle", diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index eaf1a20b..e53c20e0 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -36,6 +36,8 @@ public struct EditorConfiguration: Sendable { public var editorAssetsEndpoint: URL? /// Logs emitted at or above this level will be printed to the debug console public let logLevel: LogLevel + /// Enables logging of all network requests/responses to the native host + public let enableNetworkLogging: Bool /// Deliberately non-public – consumers should use `EditorConfigurationBuilder` to construct a configuration init( @@ -55,7 +57,8 @@ public struct EditorConfiguration: Sendable { locale: String, isNativeInserterEnabled: Bool, editorAssetsEndpoint: URL? = nil, - logLevel: LogLevel + logLevel: LogLevel, + enableNetworkLogging: Bool = false ) { self.title = title self.content = content @@ -74,6 +77,7 @@ public struct EditorConfiguration: Sendable { self.isNativeInserterEnabled = isNativeInserterEnabled self.editorAssetsEndpoint = editorAssetsEndpoint self.logLevel = logLevel + self.enableNetworkLogging = enableNetworkLogging } public func toBuilder() -> EditorConfigurationBuilder { @@ -126,6 +130,7 @@ public struct EditorConfigurationBuilder { private var isNativeInserterEnabled: Bool private var editorAssetsEndpoint: URL? private var logLevel: LogLevel + private var enableNetworkLogging: Bool public init( title: String = "", @@ -144,7 +149,8 @@ public struct EditorConfigurationBuilder { locale: String = "en", isNativeInserterEnabled: Bool = false, editorAssetsEndpoint: URL? = nil, - logLevel: LogLevel = .error + logLevel: LogLevel = .error, + enableNetworkLogging: Bool = false ){ self.title = title self.content = content @@ -163,6 +169,7 @@ public struct EditorConfigurationBuilder { self.isNativeInserterEnabled = isNativeInserterEnabled self.editorAssetsEndpoint = editorAssetsEndpoint self.logLevel = logLevel + self.enableNetworkLogging = enableNetworkLogging } public func setTitle(_ title: String) -> EditorConfigurationBuilder { @@ -267,6 +274,12 @@ public struct EditorConfigurationBuilder { return copy } + public func setEnableNetworkLogging(_ enableNetworkLogging: Bool) -> EditorConfigurationBuilder { + var copy = self + copy.enableNetworkLogging = enableNetworkLogging + return copy + } + /// Simplify conditionally applying a configuration change /// /// Sample Code: @@ -307,7 +320,8 @@ public struct EditorConfigurationBuilder { locale: locale, isNativeInserterEnabled: isNativeInserterEnabled, editorAssetsEndpoint: editorAssetsEndpoint, - logLevel: logLevel + logLevel: logLevel, + enableNetworkLogging: enableNetworkLogging ) } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 795ce516..ce176617 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -164,7 +164,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro title: '\(configuration.escapedTitle)', content: '\(configuration.escapedContent)' }, - logLevel: '\(configuration.logLevel)' + logLevel: '\(configuration.logLevel)', + enableNetworkLogging: \(configuration.enableNetworkLogging) }; localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index e7a481ee..6bb1d0e1 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -201,6 +201,7 @@ export function onNetworkRequest( requestData ) { * @property {string} [authHeader] The authentication header. * @property {string} [hideTitle] Whether to hide the title. * @property {Post} [post] The post data. + * @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method. */ /** From a69ac325e977ce32e16a2af84343e96c1257c7ac Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 15:59:37 -0500 Subject: [PATCH 09/24] task: Add iOS network logging delegate methods --- .../Sources/EditorJSMessage.swift | 2 + .../Sources/EditorViewController.swift | 6 +++ .../EditorViewControllerDelegate.swift | 48 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index d6428c06..4d99706f 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -45,6 +45,8 @@ struct EditorJSMessage { case onModalDialogClosed /// The app is emitting logging data case log + /// A network request was made + case onNetworkRequest } struct DidUpdateBlocksBody: Decodable { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index ce176617..eb54963b 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -474,6 +474,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro case .log: let log = try message.decode(EditorJSMessage.LogMessage.self) delegate?.editor(self, didLogMessage: log.message, level: log.level) + case .onNetworkRequest: + guard let requestDict = message.body as? [String: Any], + let networkRequest = NetworkRequest(from: requestDict) else { + return + } + delegate?.editor(self, didLogNetworkRequest: networkRequest) } } catch { // Capture detailed diagnostic information for crash reporting diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 471ad3cc..66f52216 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -54,6 +54,14 @@ public protocol EditorViewControllerDelegate: AnyObject { /// /// - parameter dialogType: The type of modal dialog that closed (e.g., "block-inserter", "media-library"). func editor(_ viewController: EditorViewController, didCloseModalDialog dialogType: String) + + /// Notifies the client about a network request and its response. + /// + /// This method is called when network logging is enabled via `EditorConfiguration.enableNetworkLogging`. + /// It provides visibility into all fetch-based network requests made by the editor. + /// + /// - parameter request: The network request details including URL, headers, body, response, and timing. + func editor(_ viewController: EditorViewController, didLogNetworkRequest request: NetworkRequest) } #endif @@ -156,3 +164,43 @@ public struct OpenMediaLibraryAction: Codable { case multiple([Int]) } } + +public struct NetworkRequest { + /// The request URL + public let url: String + /// The HTTP method (GET, POST, etc.) + public let method: String + /// The request headers + public let requestHeaders: [String: String] + /// The request body + public let requestBody: String? + /// The HTTP response status code + public let status: Int + /// The response headers + public let responseHeaders: [String: String] + /// The response body + public let responseBody: String? + /// The request duration in milliseconds + public let duration: Int + + init?(from dict: [AnyHashable: Any]) { + guard let url = dict["url"] as? String, + let method = dict["method"] as? String, + let requestHeaders = dict["requestHeaders"] as? [String: String], + let status = dict["status"] as? Int, + let responseHeaders = dict["responseHeaders"] as? [String: String], + let duration = dict["duration"] as? Int + else { + return nil + } + + self.url = url + self.method = method + self.requestHeaders = requestHeaders + self.requestBody = dict["requestBody"] as? String + self.status = status + self.responseHeaders = responseHeaders + self.responseBody = dict["responseBody"] as? String + self.duration = duration + } +} From d6a9ba321eaea1fca6d7d8e5ed40b79cad5c6fc2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 16:00:34 -0500 Subject: [PATCH 10/24] task: Add Android network logging delegate methods --- .../org/wordpress/gutenberg/GutenbergView.kt | 23 +++++++++++ .../org/wordpress/gutenberg/NetworkRequest.kt | 39 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/NetworkRequest.kt 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 0662065b..a7c2ae91 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -55,6 +55,7 @@ class GutenbergView : WebView { private var logJsExceptionListener: LogJsExceptionListener? = null private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null private var modalDialogStateListener: ModalDialogStateListener? = null + private var networkRequestListener: NetworkRequestListener? = null /** * Stores the contextId from the most recent openMediaLibrary call @@ -99,6 +100,10 @@ class GutenbergView : WebView { modalDialogStateListener = listener } + fun setNetworkRequestListener(listener: NetworkRequestListener) { + networkRequestListener = listener + } + fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) { onFileChooserRequested = listener } @@ -404,6 +409,10 @@ class GutenbergView : WebView { fun onModalDialogClosed(dialogType: String) } + interface NetworkRequestListener { + fun onNetworkRequest(request: NetworkRequest) + } + fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't change the editor content until it has loaded") @@ -610,6 +619,19 @@ class GutenbergView : WebView { } } + @JavascriptInterface + fun onNetworkRequest(requestData: String) { + handler.post { + try { + val json = JSONObject(requestData) + val request = NetworkRequest.fromJson(json) + networkRequestListener?.onNetworkRequest(request) + } catch (e: Exception) { + Log.e("GutenbergView", "Error parsing network request: ${e.message}") + } + } + } + fun resetFilePathCallback() { filePathCallback = null } @@ -691,6 +713,7 @@ class GutenbergView : WebView { onFileChooserRequested = null autocompleterTriggeredListener = null modalDialogStateListener = null + networkRequestListener = null handler.removeCallbacksAndMessages(null) this.destroy() } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/NetworkRequest.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/NetworkRequest.kt new file mode 100644 index 00000000..97d1fae3 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/NetworkRequest.kt @@ -0,0 +1,39 @@ +package org.wordpress.gutenberg + +import org.json.JSONObject + +data class NetworkRequest( + val url: String, + val method: String, + val requestHeaders: Map, + val requestBody: String?, + val status: Int, + val responseHeaders: Map, + val responseBody: String?, + val duration: Int +) { + companion object { + fun fromJson(json: JSONObject): NetworkRequest { + return NetworkRequest( + url = json.getString("url"), + method = json.getString("method"), + requestHeaders = jsonObjectToMap(json.getJSONObject("requestHeaders")), + requestBody = json.optString("requestBody").takeIf { it.isNotEmpty() }, + status = json.getInt("status"), + responseHeaders = jsonObjectToMap(json.getJSONObject("responseHeaders")), + responseBody = json.optString("responseBody").takeIf { it.isNotEmpty() }, + duration = json.getInt("duration") + ) + } + + private fun jsonObjectToMap(jsonObject: JSONObject): Map { + val map = mutableMapOf() + val keys = jsonObject.keys() + while (keys.hasNext()) { + val key = keys.next() + map[key] = jsonObject.getString(key) + } + return map + } + } +} From b050a4fd39b32e4b0ed1e4e0d8f3d16c4aa78423 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 16:00:49 -0500 Subject: [PATCH 11/24] task: Enable networking logging in the iOS demo app --- ios/Demo-iOS/Sources/Views/AppRootView.swift | 2 ++ ios/Demo-iOS/Sources/Views/EditorView.swift | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/ios/Demo-iOS/Sources/Views/AppRootView.swift b/ios/Demo-iOS/Sources/Views/AppRootView.swift index 81b5eb1c..93ab6547 100644 --- a/ios/Demo-iOS/Sources/Views/AppRootView.swift +++ b/ios/Demo-iOS/Sources/Views/AppRootView.swift @@ -80,6 +80,7 @@ struct AppRootView: View { .setAuthHeader(config.authHeader) .setNativeInserterEnabled(isNativeInserterEnabled) .setLogLevel(.debug) + .setEnableNetworkLogging(true) .build() self.activeEditorConfiguration = updatedConfiguration @@ -102,6 +103,7 @@ struct AppRootView: View { .setSiteApiRoot("") .setAuthHeader("") .setNativeInserterEnabled(isNativeInserterEnabled) + .setEnableNetworkLogging(true) .build() } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 3a23ae9e..f5b4e51e 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -182,6 +182,17 @@ private struct _EditorView: UIViewControllerRepresentable { func editor(_ viewController: EditorViewController, didCloseModalDialog dialogType: String) { viewModel.isModalDialogOpen = false } + + func editor(_ viewController: EditorViewController, didLogNetworkRequest request: NetworkRequest) { + print("🌐 Network Request: \(request.method) \(request.url)") + print(" Status: \(request.status), Duration: \(request.duration)ms") + if let requestBody = request.requestBody { + print(" Request Body: \(requestBody.prefix(200))...") + } + if let responseBody = request.responseBody { + print(" Response Body: \(responseBody.prefix(200))...") + } + } } } From 508e4316e688e9c780e6e92cea4cb55b1325cad9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 16:01:03 -0500 Subject: [PATCH 12/24] task: Enable networking logging in the Android demo app --- .../java/com/example/gutenbergkit/EditorActivity.kt | 12 ++++++++++++ .../java/com/example/gutenbergkit/MainActivity.kt | 1 + 2 files changed, 13 insertions(+) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 3e3a7f8f..41dc4de6 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -225,6 +225,18 @@ fun EditorScreen( hasRedoState = hasRedo } }) + setNetworkRequestListener(object : GutenbergView.NetworkRequestListener { + override fun onNetworkRequest(request: org.wordpress.gutenberg.NetworkRequest) { + android.util.Log.d("EditorActivity", "🌐 Network Request: ${request.method} ${request.url}") + android.util.Log.d("EditorActivity", " Status: ${request.status}, Duration: ${request.duration}ms") + request.requestBody?.let { + android.util.Log.d("EditorActivity", " Request Body: ${it.take(200)}...") + } + request.responseBody?.let { + android.util.Log.d("EditorActivity", " Response Body: ${it.take(200)}...") + } + } + }) start(configuration) onGutenbergViewCreated(this) } diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index 5c458da5..69956b64 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -160,6 +160,7 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa .setThemeStyles(false) .setHideTitle(false) .setCookies(emptyMap()) + .setEnableNetworkLogging(true) private fun launchEditor(configuration: EditorConfiguration) { val intent = Intent(this, EditorActivity::class.java) From 52f64e0ba0cc8dcad88897af364353a10d88734a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 16:25:40 -0500 Subject: [PATCH 13/24] fix: Prevent logging logic locking the Android response All requests were failing due to the blocking processing of the body. --- src/utils/fetch-interceptor.js | 53 ++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js index 7ac42c76..43726f12 100644 --- a/src/utils/fetch-interceptor.js +++ b/src/utils/fetch-interceptor.js @@ -56,34 +56,51 @@ export function initializeFetchInterceptor() { let response; let responseStatus; let responseHeaders = {}; - let responseBody = null; try { // Call original fetch response = await originalFetch( input, init ); - // Clone response to read body without consuming it + // Capture response metadata immediately const responseClone = response.clone(); responseStatus = response.status; responseHeaders = serializeHeaders( response.headers ); - - // Read response body from clone - responseBody = await serializeBody( responseClone ); - - // Calculate duration and log const duration = Math.round( performance.now() - startTime ); - onNetworkRequest( { - url: requestDetails.url, - method: requestDetails.method, - requestHeaders: serializeHeaders( requestDetails.headers ), - requestBody, - status: responseStatus, - responseHeaders, - responseBody, - duration, - } ); - + // Log asynchronously without blocking the response return + // This prevents Android WebView Response locking issues + serializeBody( responseClone ) + .then( ( body ) => { + onNetworkRequest( { + url: requestDetails.url, + method: requestDetails.method, + requestHeaders: serializeHeaders( + requestDetails.headers + ), + requestBody, + status: responseStatus, + responseHeaders, + responseBody: body, + duration, + } ); + } ) + .catch( ( error ) => { + // Log without body if reading fails + onNetworkRequest( { + url: requestDetails.url, + method: requestDetails.method, + requestHeaders: serializeHeaders( + requestDetails.headers + ), + requestBody, + status: responseStatus, + responseHeaders, + responseBody: `[Error reading body: ${ error.message }]`, + duration, + } ); + } ); + + // Return response immediately - don't wait for body serialization return response; } catch ( error ) { // Log failed request From 316bb7f4ca4cf09bc5ae5b495f564c377461b571 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 21 Nov 2025 16:28:13 -0500 Subject: [PATCH 14/24] fix: Custom bridge method sends expected Android data The data structure for networking logging is incompatible with `dispatchToBridge`. --- src/utils/bridge.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 6bb1d0e1..532edb18 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -188,7 +188,18 @@ export function onModalDialogClosed( dialogType ) { * @return {void} */ export function onNetworkRequest( requestData ) { - dispatchToBridge( 'onNetworkRequest', requestData ); + debug( `Bridge event: onNetworkRequest`, requestData ); + + if ( window.editorDelegate ) { + window.editorDelegate.onNetworkRequest( JSON.stringify( requestData ) ); + } + + if ( window.webkit ) { + window.webkit.messageHandlers.editorDelegate.postMessage( { + message: 'onNetworkRequest', + body: requestData, + } ); + } } /** From 2ba508313f6257b79a0f6664471090aaa9f47481 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 13:05:02 -0500 Subject: [PATCH 15/24] fix: Retain values during `toBuilder` conversion The absence of these keys resulted in resetting the value to its default when converting the configuration to a builder. --- ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index e53c20e0..6313b31b 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -97,7 +97,9 @@ public struct EditorConfiguration: Sendable { editorSettings: editorSettings, locale: locale, isNativeInserterEnabled: isNativeInserterEnabled, - editorAssetsEndpoint: editorAssetsEndpoint + editorAssetsEndpoint: editorAssetsEndpoint, + logLevel: logLevel, + enableNetworkLogging: enableNetworkLogging ) } From 5014e2abe40927f45dd5738aff6e46c1ffe62654 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 14:35:46 -0500 Subject: [PATCH 16/24] test: Assert configuration builder properties --- .../gutenberg/EditorConfigurationTest.kt | 51 +++++++++++-------- .../EditorConfigurationBuilderTests.swift | 29 +++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt index 74e972fa..2eaa4718 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt @@ -2,14 +2,12 @@ package org.wordpress.gutenberg import org.junit.Test import org.junit.Assert.* -import org.junit.Before class EditorConfigurationTest { - private lateinit var editorConfig: EditorConfiguration - @Before - fun setup() { - editorConfig = EditorConfiguration.builder() + @Test + fun `test EditorConfiguration builder sets all properties correctly`() { + val config = EditorConfiguration.builder() .setTitle("Test Title") .setContent("Test Content") .setPostId(123) @@ -22,23 +20,34 @@ class EditorConfigurationTest { .setSiteApiNamespace(arrayOf("wp/v2")) .setNamespaceExcludedPaths(arrayOf("users")) .setAuthHeader("Bearer token") + .setEditorSettings("{\"foo\":\"bar\"}") + .setLocale("fr") + .setCookies(mapOf("session" to "abc123")) + .setEnableAssetCaching(true) + .setCachedAssetHosts(setOf("example.com", "cdn.example.com")) + .setEditorAssetsEndpoint("https://example.com/assets") + .setEnableNetworkLogging(true) .build() - } - @Test - fun `test EditorConfiguration builder creates correct configuration`() { - assertEquals("Test Title", editorConfig.title) - assertEquals("Test Content", editorConfig.content) - assertEquals(123, editorConfig.postId) - assertEquals("post", editorConfig.postType) - assertTrue(editorConfig.themeStyles) - assertTrue(editorConfig.plugins) - assertFalse(editorConfig.hideTitle) - assertEquals("https://example.com", editorConfig.siteURL) - assertEquals("https://example.com/wp-json", editorConfig.siteApiRoot) - assertArrayEquals(arrayOf("wp/v2"), editorConfig.siteApiNamespace) - assertArrayEquals(arrayOf("users"), editorConfig.namespaceExcludedPaths) - assertEquals("Bearer token", editorConfig.authHeader) + assertEquals("Test Title", config.title) + assertEquals("Test Content", config.content) + assertEquals(123, config.postId) + assertEquals("post", config.postType) + assertTrue(config.themeStyles) + assertTrue(config.plugins) + assertFalse(config.hideTitle) + assertEquals("https://example.com", config.siteURL) + assertEquals("https://example.com/wp-json", config.siteApiRoot) + assertArrayEquals(arrayOf("wp/v2"), config.siteApiNamespace) + assertArrayEquals(arrayOf("users"), config.namespaceExcludedPaths) + assertEquals("Bearer token", config.authHeader) + assertEquals("{\"foo\":\"bar\"}", config.editorSettings) + assertEquals("fr", config.locale) + assertEquals(mapOf("session" to "abc123"), config.cookies) + assertTrue(config.enableAssetCaching) + assertEquals(setOf("example.com", "cdn.example.com"), config.cachedAssetHosts) + assertEquals("https://example.com/assets", config.editorAssetsEndpoint) + assertTrue(config.enableNetworkLogging) } @Test @@ -71,4 +80,4 @@ class EditorConfigurationTest { assertNotEquals(config1, config2) } -} \ No newline at end of file +} diff --git a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift index 67846412..3f12240b 100644 --- a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift @@ -23,6 +23,9 @@ struct EditorConfigurationBuilderTests { #expect(builder.editorSettings == "undefined") #expect(builder.locale == "en") #expect(builder.editorAssetsEndpoint == nil) + #expect(builder.isNativeInserterEnabled == false) + #expect(builder.logLevel == .error) + #expect(builder.enableNetworkLogging == false) } @Test("Editor Configuration to Builder") @@ -43,6 +46,9 @@ struct EditorConfigurationBuilderTests { .setEditorSettings(#"{"foo":"bar"}"#) .setLocale("fr") .setEditorAssetsEndpoint(URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) + .setNativeInserterEnabled(true) + .setLogLevel(.debug) + .setEnableNetworkLogging(true) .build() // Convert to a configuration .toBuilder() // Then back to a builder (to test the configuration->builder logic) .build() // Then back to a configuration to examine the results @@ -62,6 +68,9 @@ struct EditorConfigurationBuilderTests { #expect(configuration.editorSettings == #"{"foo":"bar"}"#) #expect(configuration.locale == "fr") #expect(configuration.editorAssetsEndpoint == URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) + #expect(configuration.isNativeInserterEnabled == true) + #expect(configuration.logLevel == .debug) + #expect(configuration.enableNetworkLogging == true) } @Test("Sets Title Correctly") @@ -157,6 +166,26 @@ struct EditorConfigurationBuilderTests { #expect(EditorConfigurationBuilder().setEditorAssetsEndpoint(URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")).build().editorAssetsEndpoint == URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) } + @Test("Sets isNativeInserterEnabled Correctly") + func editorConfigurationBuilderSetsNativeInserterEnabledCorrectly() throws { + #expect(EditorConfigurationBuilder().setNativeInserterEnabled(true).build().isNativeInserterEnabled) + #expect(!EditorConfigurationBuilder().setNativeInserterEnabled(false).build().isNativeInserterEnabled) + } + + @Test("Sets logLevel Correctly") + func editorConfigurationBuilderSetsLogLevelCorrectly() throws { + #expect(EditorConfigurationBuilder().setLogLevel(.debug).build().logLevel == .debug) + #expect(EditorConfigurationBuilder().setLogLevel(.info).build().logLevel == .info) + #expect(EditorConfigurationBuilder().setLogLevel(.warn).build().logLevel == .warn) + #expect(EditorConfigurationBuilder().setLogLevel(.error).build().logLevel == .error) + } + + @Test("Sets enableNetworkLogging Correctly") + func editorConfigurationBuilderSetsEnableNetworkLoggingCorrectly() throws { + #expect(EditorConfigurationBuilder().setEnableNetworkLogging(true).build().enableNetworkLogging) + #expect(!EditorConfigurationBuilder().setEnableNetworkLogging(false).build().enableNetworkLogging) + } + @Test("Applies values correctly") func editorConfigurationBuilderAppliesValuesCorrectly() throws { let string = "test" From e15343503b98e9d3fbb9d90e95578f791ae21cf3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 15:18:03 -0500 Subject: [PATCH 17/24] fix: Serialize various request body types Avoid logging `[Object object]` string values. --- src/utils/fetch-interceptor.js | 71 ++++++- src/utils/fetch-interceptor.test.js | 314 ++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 src/utils/fetch-interceptor.test.js diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js index 43726f12..8db4ffbd 100644 --- a/src/utils/fetch-interceptor.js +++ b/src/utils/fetch-interceptor.js @@ -41,7 +41,7 @@ export function initializeFetchInterceptor() { if ( typeof init.body === 'string' ) { requestBody = init.body; } else { - requestBody = String( init.body ); + requestBody = serializeRequestBody( init.body ); } } else if ( input instanceof Request ) { // Body might be in Request object - clone to read it @@ -156,6 +156,75 @@ function extractRequestDetails( input, init = {} ) { }; } +/** + * Serializes non-string request body objects into readable strings. + * Handles FormData, Blob, File, ArrayBuffer, and URLSearchParams. + * + * @param {*} body The request body to serialize. + * + * @return {string} The serialized body representation. + */ +function serializeRequestBody( body ) { + // FormData - serialize all entries + if ( body instanceof FormData ) { + const entries = Array.from( body.entries() ); + const fields = entries + .map( ( [ key, value ] ) => { + if ( value instanceof File ) { + return `${ key }=`; + } + if ( value instanceof Blob ) { + return `${ key }=`; + } + // Truncate long string values for readability + const stringValue = String( value ); + return `${ key }=${ + stringValue.length > 50 + ? stringValue.substring( 0, 50 ) + '...' + : stringValue + }`; + } ) + .join( ', ' ); + return `[FormData with ${ entries.length } field(s): ${ fields }]`; + } + + // File - show file details + if ( body instanceof File ) { + return `[File: ${ body.name }, ${ body.size } bytes, type: ${ + body.type || 'unknown' + }]`; + } + + // Blob - show size and type + if ( body instanceof Blob ) { + return `[Blob: ${ body.size } bytes, type: ${ + body.type || 'unknown' + }]`; + } + + // ArrayBuffer - show byte length + if ( body instanceof ArrayBuffer ) { + return `[ArrayBuffer: ${ body.byteLength } bytes]`; + } + + // URLSearchParams - convert to string + if ( body instanceof URLSearchParams ) { + return body.toString(); + } + + // ReadableStream - can't read without consuming + if ( body instanceof ReadableStream ) { + return '[ReadableStream - cannot serialize without consuming]'; + } + + // Fallback to String conversion + return String( body ); +} + /** * Reads and serializes request/response body. * Handles text, JSON, and binary data. diff --git a/src/utils/fetch-interceptor.test.js b/src/utils/fetch-interceptor.test.js new file mode 100644 index 00000000..2904ee6d --- /dev/null +++ b/src/utils/fetch-interceptor.test.js @@ -0,0 +1,314 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { initializeFetchInterceptor } from './fetch-interceptor'; +import * as bridge from './bridge'; + +vi.mock( './bridge' ); + +describe( 'fetch-interceptor request body serialization', () => { + let originalFetch; + + beforeEach( () => { + // Reset window state + delete window.__fetchInterceptorInitialized; + + // Mock bridge functions + bridge.getGBKit.mockReturnValue( { + enableNetworkLogging: true, + } ); + bridge.onNetworkRequest = vi.fn(); + + // Store original fetch + originalFetch = global.fetch; + + // Mock fetch to return a simple response + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + status: 200, + headers: new Headers( { + 'content-type': 'application/json', + } ), + clone() { + return { + headers: this.headers, + text: () => Promise.resolve( '{}' ), + blob: () => + Promise.resolve( + new Blob( [ '{}' ], { + type: 'application/json', + } ) + ), + }; + }, + } ) + ); + } ); + + afterEach( () => { + global.fetch = originalFetch; + vi.clearAllMocks(); + } ); + + it( 'should serialize FormData with files correctly', async () => { + initializeFetchInterceptor(); + + // Create a FormData with a file + const formData = new FormData(); + const file = new File( [ 'test content' ], 'test.jpg', { + type: 'image/jpeg', + } ); + formData.append( 'file', file ); + formData.append( 'post', '123' ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: formData, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toContain( '[FormData with' ); + expect( loggedRequest.requestBody ).toContain( 'file= { + initializeFetchInterceptor(); + + const blob = new Blob( [ 'binary content' ], { type: 'image/png' } ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: blob, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toMatch( + /\[Blob: \d+ bytes, type: image\/png\]/ + ); + } ); + + it( 'should serialize File bodies correctly', async () => { + initializeFetchInterceptor(); + + const file = new File( [ 'file content' ], 'document.pdf', { + type: 'application/pdf', + } ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: file, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toMatch( + /\[File: document\.pdf, \d+ bytes, type: application\/pdf\]/ + ); + } ); + + it( 'should serialize ArrayBuffer bodies correctly', async () => { + initializeFetchInterceptor(); + + const buffer = new ArrayBuffer( 1024 ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: buffer, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toBe( '[ArrayBuffer: 1024 bytes]' ); + } ); + + it( 'should serialize URLSearchParams bodies correctly', async () => { + initializeFetchInterceptor(); + + const params = new URLSearchParams(); + params.append( 'key1', 'value1' ); + params.append( 'key2', 'value2' ); + + await window.fetch( 'https://example.com/api', { + method: 'POST', + body: params, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toBe( 'key1=value1&key2=value2' ); + } ); + + it( 'should handle string bodies correctly', async () => { + initializeFetchInterceptor(); + + const jsonString = JSON.stringify( { test: 'data' } ); + + await window.fetch( 'https://example.com/api', { + method: 'POST', + body: jsonString, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toBe( jsonString ); + } ); + + it( 'should handle FormData with mixed content types', async () => { + initializeFetchInterceptor(); + + const formData = new FormData(); + formData.append( 'text', 'simple text value' ); + formData.append( + 'file1', + new File( [ 'content1' ], 'image.png', { type: 'image/png' } ) + ); + formData.append( + 'file2', + new File( [ 'content2' ], 'doc.pdf', { + type: 'application/pdf', + } ) + ); + formData.append( + 'blob', + new Blob( [ 'blob data' ], { type: 'application/octet-stream' } ) + ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: formData, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toContain( + '[FormData with 4 field(s):' + ); + expect( loggedRequest.requestBody ).toContain( + 'text=simple text value' + ); + expect( loggedRequest.requestBody ).toContain( + 'file1= { + initializeFetchInterceptor(); + + const formData = new FormData(); + const longString = 'a'.repeat( 100 ); + formData.append( 'longField', longString ); + + await window.fetch( 'https://example.com/api', { + method: 'POST', + body: formData, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toContain( 'longField=' ); + expect( loggedRequest.requestBody ).toContain( '...' ); + expect( loggedRequest.requestBody.length ).toBeLessThan( + longString.length + 50 + ); + } ); + + it( 'should handle ReadableStream bodies', async () => { + initializeFetchInterceptor(); + + const stream = new ReadableStream( { + start( controller ) { + controller.enqueue( new Uint8Array( [ 1, 2, 3 ] ) ); + controller.close(); + }, + } ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: stream, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toBe( + '[ReadableStream - cannot serialize without consuming]' + ); + } ); + + it( 'should not initialize when network logging is disabled', () => { + // Store the current fetch (which is the mock from beforeEach) + const currentFetch = window.fetch; + + bridge.getGBKit.mockReturnValue( { + enableNetworkLogging: false, + } ); + + initializeFetchInterceptor(); + + // Should not have initialized + expect( window.__fetchInterceptorInitialized ).toBeUndefined(); + // Fetch should not have been wrapped (should still be the same mock) + expect( window.fetch ).toBe( currentFetch ); + } ); + + it( 'should handle missing body gracefully', async () => { + initializeFetchInterceptor(); + + await window.fetch( 'https://example.com/api' ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toBeNull(); + } ); +} ); From 46065262f9e5f4cc809d77154d528cd1fe162287 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 15:26:00 -0500 Subject: [PATCH 18/24] refactor: Organize fetch interceptor tests --- src/utils/fetch-interceptor.test.js | 411 ++++++++++++++-------------- 1 file changed, 212 insertions(+), 199 deletions(-) diff --git a/src/utils/fetch-interceptor.test.js b/src/utils/fetch-interceptor.test.js index 2904ee6d..7b1362ce 100644 --- a/src/utils/fetch-interceptor.test.js +++ b/src/utils/fetch-interceptor.test.js @@ -11,19 +11,13 @@ import * as bridge from './bridge'; vi.mock( './bridge' ); -describe( 'fetch-interceptor request body serialization', () => { +describe( 'initializeFetchInterceptor', () => { let originalFetch; beforeEach( () => { // Reset window state delete window.__fetchInterceptorInitialized; - // Mock bridge functions - bridge.getGBKit.mockReturnValue( { - enableNetworkLogging: true, - } ); - bridge.onNetworkRequest = vi.fn(); - // Store original fetch originalFetch = global.fetch; @@ -49,6 +43,11 @@ describe( 'fetch-interceptor request body serialization', () => { }, } ) ); + + bridge.getGBKit.mockReturnValue( { + enableNetworkLogging: true, + } ); + bridge.onNetworkRequest = vi.fn(); } ); afterEach( () => { @@ -56,259 +55,273 @@ describe( 'fetch-interceptor request body serialization', () => { vi.clearAllMocks(); } ); - it( 'should serialize FormData with files correctly', async () => { - initializeFetchInterceptor(); - - // Create a FormData with a file - const formData = new FormData(); - const file = new File( [ 'test content' ], 'test.jpg', { - type: 'image/jpeg', - } ); - formData.append( 'file', file ); - formData.append( 'post', '123' ); + it( 'should not initialize when network logging is disabled', () => { + // Store the current fetch (which is the mock from beforeEach) + const currentFetch = window.fetch; - await window.fetch( 'https://example.com/upload', { - method: 'POST', - body: formData, + bridge.getGBKit.mockReturnValue( { + enableNetworkLogging: false, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toContain( '[FormData with' ); - expect( loggedRequest.requestBody ).toContain( 'file= { initializeFetchInterceptor(); - const blob = new Blob( [ 'binary content' ], { type: 'image/png' } ); + // Should not have initialized + expect( window.__fetchInterceptorInitialized ).toBeUndefined(); + // Fetch should not have been wrapped (should still be the same mock) + expect( window.fetch ).toBe( currentFetch ); + } ); - await window.fetch( 'https://example.com/upload', { - method: 'POST', - body: blob, + describe( 'request body serialization', () => { + it( 'should serialize FormData with files correctly', async () => { + initializeFetchInterceptor(); + + // Create a FormData with a file + const formData = new FormData(); + const file = new File( [ 'test content' ], 'test.jpg', { + type: 'image/jpeg', + } ); + formData.append( 'file', file ); + formData.append( 'post', '123' ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: formData, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toContain( '[FormData with' ); + expect( loggedRequest.requestBody ).toContain( + 'file= setTimeout( resolve, 10 ) ); + it( 'should serialize Blob bodies correctly', async () => { + initializeFetchInterceptor(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + const blob = new Blob( [ 'binary content' ], { + type: 'image/png', + } ); - expect( loggedRequest.requestBody ).toMatch( - /\[Blob: \d+ bytes, type: image\/png\]/ - ); - } ); + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: blob, + } ); - it( 'should serialize File bodies correctly', async () => { - initializeFetchInterceptor(); + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - const file = new File( [ 'file content' ], 'document.pdf', { - type: 'application/pdf', - } ); + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - await window.fetch( 'https://example.com/upload', { - method: 'POST', - body: file, + expect( loggedRequest.requestBody ).toMatch( + /\[Blob: \d+ bytes, type: image\/png\]/ + ); } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + it( 'should serialize File bodies correctly', async () => { + initializeFetchInterceptor(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + const file = new File( [ 'file content' ], 'document.pdf', { + type: 'application/pdf', + } ); - expect( loggedRequest.requestBody ).toMatch( - /\[File: document\.pdf, \d+ bytes, type: application\/pdf\]/ - ); - } ); + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: file, + } ); - it( 'should serialize ArrayBuffer bodies correctly', async () => { - initializeFetchInterceptor(); + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - const buffer = new ArrayBuffer( 1024 ); + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - await window.fetch( 'https://example.com/upload', { - method: 'POST', - body: buffer, + expect( loggedRequest.requestBody ).toMatch( + /\[File: document\.pdf, \d+ bytes, type: application\/pdf\]/ + ); } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + it( 'should serialize ArrayBuffer bodies correctly', async () => { + initializeFetchInterceptor(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + const buffer = new ArrayBuffer( 1024 ); - expect( loggedRequest.requestBody ).toBe( '[ArrayBuffer: 1024 bytes]' ); - } ); + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: buffer, + } ); - it( 'should serialize URLSearchParams bodies correctly', async () => { - initializeFetchInterceptor(); + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - const params = new URLSearchParams(); - params.append( 'key1', 'value1' ); - params.append( 'key2', 'value2' ); + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - await window.fetch( 'https://example.com/api', { - method: 'POST', - body: params, + expect( loggedRequest.requestBody ).toBe( + '[ArrayBuffer: 1024 bytes]' + ); } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + it( 'should serialize URLSearchParams bodies correctly', async () => { + initializeFetchInterceptor(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + const params = new URLSearchParams(); + params.append( 'key1', 'value1' ); + params.append( 'key2', 'value2' ); - expect( loggedRequest.requestBody ).toBe( 'key1=value1&key2=value2' ); - } ); + await window.fetch( 'https://example.com/api', { + method: 'POST', + body: params, + } ); - it( 'should handle string bodies correctly', async () => { - initializeFetchInterceptor(); + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - const jsonString = JSON.stringify( { test: 'data' } ); + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - await window.fetch( 'https://example.com/api', { - method: 'POST', - body: jsonString, + expect( loggedRequest.requestBody ).toBe( + 'key1=value1&key2=value2' + ); } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + it( 'should handle string bodies correctly', async () => { + initializeFetchInterceptor(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + const jsonString = JSON.stringify( { test: 'data' } ); - expect( loggedRequest.requestBody ).toBe( jsonString ); - } ); + await window.fetch( 'https://example.com/api', { + method: 'POST', + body: jsonString, + } ); - it( 'should handle FormData with mixed content types', async () => { - initializeFetchInterceptor(); + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - const formData = new FormData(); - formData.append( 'text', 'simple text value' ); - formData.append( - 'file1', - new File( [ 'content1' ], 'image.png', { type: 'image/png' } ) - ); - formData.append( - 'file2', - new File( [ 'content2' ], 'doc.pdf', { - type: 'application/pdf', - } ) - ); - formData.append( - 'blob', - new Blob( [ 'blob data' ], { type: 'application/octet-stream' } ) - ); + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - await window.fetch( 'https://example.com/upload', { - method: 'POST', - body: formData, + expect( loggedRequest.requestBody ).toBe( jsonString ); } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toContain( - '[FormData with 4 field(s):' - ); - expect( loggedRequest.requestBody ).toContain( - 'text=simple text value' - ); - expect( loggedRequest.requestBody ).toContain( - 'file1= { - initializeFetchInterceptor(); - - const formData = new FormData(); - const longString = 'a'.repeat( 100 ); - formData.append( 'longField', longString ); - - await window.fetch( 'https://example.com/api', { - method: 'POST', - body: formData, + it( 'should handle FormData with mixed content types', async () => { + initializeFetchInterceptor(); + + const formData = new FormData(); + formData.append( 'text', 'simple text value' ); + formData.append( + 'file1', + new File( [ 'content1' ], 'image.png', { type: 'image/png' } ) + ); + formData.append( + 'file2', + new File( [ 'content2' ], 'doc.pdf', { + type: 'application/pdf', + } ) + ); + formData.append( + 'blob', + new Blob( [ 'blob data' ], { + type: 'application/octet-stream', + } ) + ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: formData, + } ); + + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + + expect( loggedRequest.requestBody ).toContain( + '[FormData with 4 field(s):' + ); + expect( loggedRequest.requestBody ).toContain( + 'text=simple text value' + ); + expect( loggedRequest.requestBody ).toContain( + 'file1= setTimeout( resolve, 10 ) ); + it( 'should truncate long string values in FormData', async () => { + initializeFetchInterceptor(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + const formData = new FormData(); + const longString = 'a'.repeat( 100 ); + formData.append( 'longField', longString ); - expect( loggedRequest.requestBody ).toContain( 'longField=' ); - expect( loggedRequest.requestBody ).toContain( '...' ); - expect( loggedRequest.requestBody.length ).toBeLessThan( - longString.length + 50 - ); - } ); + await window.fetch( 'https://example.com/api', { + method: 'POST', + body: formData, + } ); - it( 'should handle ReadableStream bodies', async () => { - initializeFetchInterceptor(); + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - const stream = new ReadableStream( { - start( controller ) { - controller.enqueue( new Uint8Array( [ 1, 2, 3 ] ) ); - controller.close(); - }, - } ); + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - await window.fetch( 'https://example.com/upload', { - method: 'POST', - body: stream, + expect( loggedRequest.requestBody ).toContain( 'longField=' ); + expect( loggedRequest.requestBody ).toContain( '...' ); + expect( loggedRequest.requestBody.length ).toBeLessThan( + longString.length + 50 + ); } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + it( 'should handle ReadableStream bodies', async () => { + initializeFetchInterceptor(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + const stream = new ReadableStream( { + start( controller ) { + controller.enqueue( new Uint8Array( [ 1, 2, 3 ] ) ); + controller.close(); + }, + } ); - expect( loggedRequest.requestBody ).toBe( - '[ReadableStream - cannot serialize without consuming]' - ); - } ); + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: stream, + } ); - it( 'should not initialize when network logging is disabled', () => { - // Store the current fetch (which is the mock from beforeEach) - const currentFetch = window.fetch; + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - bridge.getGBKit.mockReturnValue( { - enableNetworkLogging: false, - } ); + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - initializeFetchInterceptor(); - - // Should not have initialized - expect( window.__fetchInterceptorInitialized ).toBeUndefined(); - // Fetch should not have been wrapped (should still be the same mock) - expect( window.fetch ).toBe( currentFetch ); - } ); + expect( loggedRequest.requestBody ).toBe( + '[ReadableStream - cannot serialize without consuming]' + ); + } ); - it( 'should handle missing body gracefully', async () => { - initializeFetchInterceptor(); + it( 'should handle missing body gracefully', async () => { + initializeFetchInterceptor(); - await window.fetch( 'https://example.com/api' ); + await window.fetch( 'https://example.com/api' ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + // Wait for async logging to complete + await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; + expect( bridge.onNetworkRequest ).toHaveBeenCalled(); + const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - expect( loggedRequest.requestBody ).toBeNull(); + expect( loggedRequest.requestBody ).toBeNull(); + } ); } ); } ); From 2fde120c3d1f20cc2ef720bd987e4e6be43a3fc6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 15:29:26 -0500 Subject: [PATCH 19/24] refactor: Extract async logging test helper --- src/utils/fetch-interceptor.test.js | 35 +++++++++++++---------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/utils/fetch-interceptor.test.js b/src/utils/fetch-interceptor.test.js index 7b1362ce..e51952fb 100644 --- a/src/utils/fetch-interceptor.test.js +++ b/src/utils/fetch-interceptor.test.js @@ -11,6 +11,11 @@ import * as bridge from './bridge'; vi.mock( './bridge' ); +// Helper to await the nested, non-blocking async logging that occurs within the +// fetch interceptor. +const waitForAsyncLogging = () => + new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + describe( 'initializeFetchInterceptor', () => { let originalFetch; @@ -88,8 +93,7 @@ describe( 'initializeFetchInterceptor', () => { body: formData, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -113,8 +117,7 @@ describe( 'initializeFetchInterceptor', () => { body: blob, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -136,8 +139,7 @@ describe( 'initializeFetchInterceptor', () => { body: file, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -157,8 +159,7 @@ describe( 'initializeFetchInterceptor', () => { body: buffer, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -180,8 +181,7 @@ describe( 'initializeFetchInterceptor', () => { body: params, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -201,8 +201,7 @@ describe( 'initializeFetchInterceptor', () => { body: jsonString, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -237,8 +236,7 @@ describe( 'initializeFetchInterceptor', () => { body: formData, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -271,8 +269,7 @@ describe( 'initializeFetchInterceptor', () => { body: formData, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -299,8 +296,7 @@ describe( 'initializeFetchInterceptor', () => { body: stream, } ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; @@ -315,8 +311,7 @@ describe( 'initializeFetchInterceptor', () => { await window.fetch( 'https://example.com/api' ); - // Wait for async logging to complete - await new Promise( ( resolve ) => setTimeout( resolve, 10 ) ); + await waitForAsyncLogging(); expect( bridge.onNetworkRequest ).toHaveBeenCalled(); const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; From d911eb34d38470015eb7aaab7c396646a499a389 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 15:37:02 -0500 Subject: [PATCH 20/24] refactor: Prefer Vitest utilities over mock call extraction Improve assertion explicitness. --- src/utils/fetch-interceptor.test.js | 155 +++++++++++++++++----------- 1 file changed, 96 insertions(+), 59 deletions(-) diff --git a/src/utils/fetch-interceptor.test.js b/src/utils/fetch-interceptor.test.js index e51952fb..de740e3c 100644 --- a/src/utils/fetch-interceptor.test.js +++ b/src/utils/fetch-interceptor.test.js @@ -95,14 +95,23 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toContain( '[FormData with' ); - expect( loggedRequest.requestBody ).toContain( - 'file= { @@ -119,11 +128,12 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toMatch( - /\[Blob: \d+ bytes, type: image\/png\]/ + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringMatching( + /\[Blob: \d+ bytes, type: image\/png\]/ + ), + } ) ); } ); @@ -141,11 +151,12 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toMatch( - /\[File: document\.pdf, \d+ bytes, type: application\/pdf\]/ + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringMatching( + /\[File: document\.pdf, \d+ bytes, type: application\/pdf\]/ + ), + } ) ); } ); @@ -161,11 +172,10 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toBe( - '[ArrayBuffer: 1024 bytes]' + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: '[ArrayBuffer: 1024 bytes]', + } ) ); } ); @@ -183,11 +193,10 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toBe( - 'key1=value1&key2=value2' + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: 'key1=value1&key2=value2', + } ) ); } ); @@ -203,10 +212,11 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toBe( jsonString ); + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: jsonString, + } ) + ); } ); it( 'should handle FormData with mixed content types', async () => { @@ -238,23 +248,40 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toContain( - '[FormData with 4 field(s):' + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( + '[FormData with 4 field(s):' + ), + } ) ); - expect( loggedRequest.requestBody ).toContain( - 'text=simple text value' + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( + 'text=simple text value' + ), + } ) ); - expect( loggedRequest.requestBody ).toContain( - 'file1= { @@ -271,13 +298,22 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toContain( 'longField=' ); - expect( loggedRequest.requestBody ).toContain( '...' ); - expect( loggedRequest.requestBody.length ).toBeLessThan( - longString.length + 50 + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( 'longField=' ), + } ) + ); + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( '...' ), + } ) + ); + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringMatching( + new RegExp( `.{1,${ longString.length + 50 }}` ) + ), + } ) ); } ); @@ -298,11 +334,11 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toBe( - '[ReadableStream - cannot serialize without consuming]' + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: + '[ReadableStream - cannot serialize without consuming]', + } ) ); } ); @@ -313,10 +349,11 @@ describe( 'initializeFetchInterceptor', () => { await waitForAsyncLogging(); - expect( bridge.onNetworkRequest ).toHaveBeenCalled(); - const loggedRequest = bridge.onNetworkRequest.mock.calls[ 0 ][ 0 ]; - - expect( loggedRequest.requestBody ).toBeNull(); + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: null, + } ) + ); } ); } ); } ); From ab947c83200c7f068cdd855233bb031bd96c5d44 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 15:53:15 -0500 Subject: [PATCH 21/24] fix: Parse headers from both Header objects and plain objects --- src/utils/fetch-interceptor.js | 30 ++++++- src/utils/fetch-interceptor.test.js | 120 ++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js index 8db4ffbd..5f456b31 100644 --- a/src/utils/fetch-interceptor.js +++ b/src/utils/fetch-interceptor.js @@ -146,7 +146,22 @@ function extractRequestDetails( input, init = {} ) { } else if ( input instanceof Request ) { url = input.url; method = input.method; - headers = input.headers; + // Merge Request headers with init.headers (init takes precedence, like native fetch) + if ( init.headers ) { + const requestHeaders = serializeHeaders( input.headers ); + const initHeaders = serializeHeaders( init.headers ); + // Merge with case-insensitive key matching (lowercase all keys) + const merged = {}; + Object.entries( requestHeaders ).forEach( ( [ key, value ] ) => { + merged[ key.toLowerCase() ] = value; + } ); + Object.entries( initHeaders ).forEach( ( [ key, value ] ) => { + merged[ key.toLowerCase() ] = value; + } ); + headers = merged; + } else { + headers = input.headers; + } } return { @@ -272,18 +287,27 @@ async function serializeBody( source ) { } /** - * Serializes Headers object to a plain object. + * Serializes Headers object or plain object to a plain object. * - * @param {Headers} headers The Headers object to serialize. + * @param {Headers|Object} headers The Headers object or plain object to serialize. * * @return {Object} Plain object representation of headers. */ function serializeHeaders( headers ) { const result = {}; + + // Handle Headers object (has forEach method) if ( headers && typeof headers.forEach === 'function' ) { headers.forEach( ( value, key ) => { result[ key ] = value; } ); } + // Handle plain object (from init.headers) + else if ( headers && typeof headers === 'object' ) { + Object.entries( headers ).forEach( ( [ key, value ] ) => { + result[ key ] = value; + } ); + } + return result; } diff --git a/src/utils/fetch-interceptor.test.js b/src/utils/fetch-interceptor.test.js index de740e3c..b7004521 100644 --- a/src/utils/fetch-interceptor.test.js +++ b/src/utils/fetch-interceptor.test.js @@ -76,6 +76,126 @@ describe( 'initializeFetchInterceptor', () => { expect( window.fetch ).toBe( currentFetch ); } ); + describe( 'request header capture', () => { + it( 'should capture headers from plain object with string URL', async () => { + initializeFetchInterceptor(); + + await window.fetch( 'https://example.com/api', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + 'X-Custom-Header': 'custom-value', + }, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestHeaders: expect.objectContaining( { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + 'X-Custom-Header': 'custom-value', + } ), + } ) + ); + } ); + + it( 'should capture headers from Request object', async () => { + initializeFetchInterceptor(); + + const request = new Request( 'https://example.com/api', { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-Request-ID': '12345', + }, + } ); + + await window.fetch( request ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestHeaders: expect.objectContaining( { + accept: 'application/json', + 'x-request-id': '12345', + } ), + } ) + ); + } ); + + it( 'should merge Request headers with init override', async () => { + initializeFetchInterceptor(); + + const request = new Request( 'https://example.com/api', { + headers: { + 'Content-Type': 'application/json', + 'X-Original': 'original-value', + }, + } ); + + await window.fetch( request, { + headers: { + 'Content-Type': 'application/xml', // Override + 'X-Additional': 'additional-value', // Additional + }, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestHeaders: expect.objectContaining( { + 'content-type': 'application/xml', // Overridden (lowercase) + 'x-original': 'original-value', // Preserved (lowercase) + 'x-additional': 'additional-value', // Added (lowercase) + } ), + } ) + ); + } ); + + it( 'should handle empty headers', async () => { + initializeFetchInterceptor(); + + await window.fetch( 'https://example.com/api' ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestHeaders: {}, + } ) + ); + } ); + + it( 'should handle Headers instance', async () => { + initializeFetchInterceptor(); + + const headers = new Headers(); + headers.append( 'Authorization', 'Bearer token123' ); + headers.append( 'Content-Type', 'application/json' ); + + await window.fetch( 'https://example.com/api', { + method: 'POST', + headers, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestHeaders: expect.objectContaining( { + authorization: 'Bearer token123', + 'content-type': 'application/json', + } ), + } ) + ); + } ); + } ); + describe( 'request body serialization', () => { it( 'should serialize FormData with files correctly', async () => { initializeFetchInterceptor(); From 485683df427b548ff5e6cd3bd3eaea6d4743fc95 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 24 Nov 2025 15:54:26 -0500 Subject: [PATCH 22/24] feat: Demo app logs headers --- .../com/example/gutenbergkit/EditorActivity.kt | 18 ++++++++++++++++++ ios/Demo-iOS/Sources/Views/EditorView.swift | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 41dc4de6..2ff51d06 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -229,9 +229,27 @@ fun EditorScreen( override fun onNetworkRequest(request: org.wordpress.gutenberg.NetworkRequest) { android.util.Log.d("EditorActivity", "🌐 Network Request: ${request.method} ${request.url}") android.util.Log.d("EditorActivity", " Status: ${request.status}, Duration: ${request.duration}ms") + + // Log request headers + if (request.requestHeaders.isNotEmpty()) { + android.util.Log.d("EditorActivity", " Request Headers:") + request.requestHeaders.toSortedMap().forEach { (key, value) -> + android.util.Log.d("EditorActivity", " $key: $value") + } + } + request.requestBody?.let { android.util.Log.d("EditorActivity", " Request Body: ${it.take(200)}...") } + + // Log response headers + if (request.responseHeaders.isNotEmpty()) { + android.util.Log.d("EditorActivity", " Response Headers:") + request.responseHeaders.toSortedMap().forEach { (key, value) -> + android.util.Log.d("EditorActivity", " $key: $value") + } + } + request.responseBody?.let { android.util.Log.d("EditorActivity", " Response Body: ${it.take(200)}...") } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index f5b4e51e..1a27eaa4 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -186,9 +186,27 @@ private struct _EditorView: UIViewControllerRepresentable { func editor(_ viewController: EditorViewController, didLogNetworkRequest request: NetworkRequest) { print("🌐 Network Request: \(request.method) \(request.url)") print(" Status: \(request.status), Duration: \(request.duration)ms") + + // Log request headers + if !request.requestHeaders.isEmpty { + print(" Request Headers:") + for (key, value) in request.requestHeaders.sorted(by: { $0.key < $1.key }) { + print(" \(key): \(value)") + } + } + if let requestBody = request.requestBody { print(" Request Body: \(requestBody.prefix(200))...") } + + // Log response headers + if !request.responseHeaders.isEmpty { + print(" Response Headers:") + for (key, value) in request.responseHeaders.sorted(by: { $0.key < $1.key }) { + print(" \(key): \(value)") + } + } + if let responseBody = request.responseBody { print(" Response Body: \(responseBody.prefix(200))...") } From a32f4039e371dfde58525fb412bff704d3922516 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 1 Dec 2025 10:48:23 -0500 Subject: [PATCH 23/24] refactor: Rename `NetworkRequest` to `RecordedNetworkRequest` Further clarify both request and response data is captured. --- .../main/java/org/wordpress/gutenberg/GutenbergView.kt | 4 ++-- .../{NetworkRequest.kt => RecordedNetworkRequest.kt} | 8 +++++--- .../main/java/com/example/gutenbergkit/EditorActivity.kt | 2 +- ios/Demo-iOS/Sources/Views/EditorView.swift | 2 +- .../GutenbergKit/Sources/EditorViewController.swift | 2 +- .../Sources/EditorViewControllerDelegate.swift | 4 ++-- 6 files changed, 12 insertions(+), 10 deletions(-) rename android/Gutenberg/src/main/java/org/wordpress/gutenberg/{NetworkRequest.kt => RecordedNetworkRequest.kt} (84%) 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 a7c2ae91..6ea3abea 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -410,7 +410,7 @@ class GutenbergView : WebView { } interface NetworkRequestListener { - fun onNetworkRequest(request: NetworkRequest) + fun onNetworkRequest(request: RecordedNetworkRequest) } fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) { @@ -624,7 +624,7 @@ class GutenbergView : WebView { handler.post { try { val json = JSONObject(requestData) - val request = NetworkRequest.fromJson(json) + val request = RecordedNetworkRequest.fromJson(json) networkRequestListener?.onNetworkRequest(request) } catch (e: Exception) { Log.e("GutenbergView", "Error parsing network request: ${e.message}") diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/NetworkRequest.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RecordedNetworkRequest.kt similarity index 84% rename from android/Gutenberg/src/main/java/org/wordpress/gutenberg/NetworkRequest.kt rename to android/Gutenberg/src/main/java/org/wordpress/gutenberg/RecordedNetworkRequest.kt index 97d1fae3..7d6c7881 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/NetworkRequest.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RecordedNetworkRequest.kt @@ -2,24 +2,26 @@ package org.wordpress.gutenberg import org.json.JSONObject -data class NetworkRequest( +data class RecordedNetworkRequest( val url: String, val method: String, val requestHeaders: Map, val requestBody: String?, val status: Int, + val statusText: String, val responseHeaders: Map, val responseBody: String?, val duration: Int ) { companion object { - fun fromJson(json: JSONObject): NetworkRequest { - return NetworkRequest( + fun fromJson(json: JSONObject): RecordedNetworkRequest { + return RecordedNetworkRequest( url = json.getString("url"), method = json.getString("method"), requestHeaders = jsonObjectToMap(json.getJSONObject("requestHeaders")), requestBody = json.optString("requestBody").takeIf { it.isNotEmpty() }, status = json.getInt("status"), + statusText = json.optString("statusText", ""), responseHeaders = jsonObjectToMap(json.getJSONObject("responseHeaders")), responseBody = json.optString("responseBody").takeIf { it.isNotEmpty() }, duration = json.getInt("duration") diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 2ff51d06..fc025f5e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -226,7 +226,7 @@ fun EditorScreen( } }) setNetworkRequestListener(object : GutenbergView.NetworkRequestListener { - override fun onNetworkRequest(request: org.wordpress.gutenberg.NetworkRequest) { + override fun onNetworkRequest(request: org.wordpress.gutenberg.RecordedNetworkRequest) { android.util.Log.d("EditorActivity", "🌐 Network Request: ${request.method} ${request.url}") android.util.Log.d("EditorActivity", " Status: ${request.status}, Duration: ${request.duration}ms") diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 1a27eaa4..6c34345e 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -183,7 +183,7 @@ private struct _EditorView: UIViewControllerRepresentable { viewModel.isModalDialogOpen = false } - func editor(_ viewController: EditorViewController, didLogNetworkRequest request: NetworkRequest) { + func editor(_ viewController: EditorViewController, didLogNetworkRequest request: RecordedNetworkRequest) { print("🌐 Network Request: \(request.method) \(request.url)") print(" Status: \(request.status), Duration: \(request.duration)ms") diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index eb54963b..99ac71b6 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -476,7 +476,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro delegate?.editor(self, didLogMessage: log.message, level: log.level) case .onNetworkRequest: guard let requestDict = message.body as? [String: Any], - let networkRequest = NetworkRequest(from: requestDict) else { + let networkRequest = RecordedNetworkRequest(from: requestDict) else { return } delegate?.editor(self, didLogNetworkRequest: networkRequest) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 66f52216..39d84475 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -61,7 +61,7 @@ public protocol EditorViewControllerDelegate: AnyObject { /// It provides visibility into all fetch-based network requests made by the editor. /// /// - parameter request: The network request details including URL, headers, body, response, and timing. - func editor(_ viewController: EditorViewController, didLogNetworkRequest request: NetworkRequest) + func editor(_ viewController: EditorViewController, didLogNetworkRequest request: RecordedNetworkRequest) } #endif @@ -165,7 +165,7 @@ public struct OpenMediaLibraryAction: Codable { } } -public struct NetworkRequest { +public struct RecordedNetworkRequest { /// The request URL public let url: String /// The HTTP method (GET, POST, etc.) From e90a4184f2d8dfc136e94742ebf8daf13bba4178 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 1 Dec 2025 10:49:08 -0500 Subject: [PATCH 24/24] task: Report status text --- .../example/gutenbergkit/EditorActivity.kt | 2 +- ios/Demo-iOS/Sources/Views/EditorView.swift | 2 +- .../EditorViewControllerDelegate.swift | 3 + src/utils/bridge.js | 1 + src/utils/fetch-interceptor.js | 60 +++++++++++++++++++ src/utils/fetch-interceptor.test.js | 41 +++++++++++++ 6 files changed, 107 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index fc025f5e..f93fce74 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -228,7 +228,7 @@ fun EditorScreen( setNetworkRequestListener(object : GutenbergView.NetworkRequestListener { override fun onNetworkRequest(request: org.wordpress.gutenberg.RecordedNetworkRequest) { android.util.Log.d("EditorActivity", "🌐 Network Request: ${request.method} ${request.url}") - android.util.Log.d("EditorActivity", " Status: ${request.status}, Duration: ${request.duration}ms") + android.util.Log.d("EditorActivity", " Status: ${request.status} ${request.statusText}, Duration: ${request.duration}ms") // Log request headers if (request.requestHeaders.isNotEmpty()) { diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 6c34345e..f97e8962 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -185,7 +185,7 @@ private struct _EditorView: UIViewControllerRepresentable { func editor(_ viewController: EditorViewController, didLogNetworkRequest request: RecordedNetworkRequest) { print("🌐 Network Request: \(request.method) \(request.url)") - print(" Status: \(request.status), Duration: \(request.duration)ms") + print(" Status: \(request.status) \(request.statusText), Duration: \(request.duration)ms") // Log request headers if !request.requestHeaders.isEmpty { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 39d84475..3fdd5d16 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -176,6 +176,8 @@ public struct RecordedNetworkRequest { public let requestBody: String? /// The HTTP response status code public let status: Int + /// The HTTP response status text (e.g., "OK", "Not Found") + public let statusText: String /// The response headers public let responseHeaders: [String: String] /// The response body @@ -199,6 +201,7 @@ public struct RecordedNetworkRequest { self.requestHeaders = requestHeaders self.requestBody = dict["requestBody"] as? String self.status = status + self.statusText = dict["statusText"] as? String ?? "" self.responseHeaders = responseHeaders self.responseBody = dict["responseBody"] as? String self.duration = duration diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 532edb18..93345ba9 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -181,6 +181,7 @@ export function onModalDialogClosed( dialogType ) { * @param {Object|null} requestData.requestHeaders The request headers object. * @param {string|null} requestData.requestBody The request body. * @param {number} requestData.status The HTTP response status code. + * @param {string} requestData.statusText The HTTP response status text (e.g., "OK", "Not Found"). * @param {Object|null} requestData.responseHeaders The response headers object. * @param {string|null} requestData.responseBody The response body. * @param {number} requestData.duration The request duration in milliseconds. diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js index 5f456b31..5144cf18 100644 --- a/src/utils/fetch-interceptor.js +++ b/src/utils/fetch-interceptor.js @@ -64,6 +64,8 @@ export function initializeFetchInterceptor() { // Capture response metadata immediately const responseClone = response.clone(); responseStatus = response.status; + const responseStatusText = + response.statusText || getStatusText( response.status ); responseHeaders = serializeHeaders( response.headers ); const duration = Math.round( performance.now() - startTime ); @@ -79,6 +81,7 @@ export function initializeFetchInterceptor() { ), requestBody, status: responseStatus, + statusText: responseStatusText, responseHeaders, responseBody: body, duration, @@ -94,6 +97,7 @@ export function initializeFetchInterceptor() { ), requestBody, status: responseStatus, + statusText: responseStatusText, responseHeaders, responseBody: `[Error reading body: ${ error.message }]`, duration, @@ -112,6 +116,7 @@ export function initializeFetchInterceptor() { requestHeaders: serializeHeaders( requestDetails.headers ), requestBody, status: 0, + statusText: '', responseHeaders: {}, responseBody: `[Network error: ${ error.message }]`, duration, @@ -311,3 +316,58 @@ function serializeHeaders( headers ) { return result; } + +/** + * Maps HTTP status codes to their standard status text. + * Used as fallback when response.statusText is empty (common with HTTP/2). + * + * @param {number} status The HTTP status code. + * + * @return {string} The corresponding status text, or empty string if unknown. + */ +function getStatusText( status ) { + const statusTexts = { + // 1xx Informational + 100: 'Continue', + 101: 'Switching Protocols', + // 2xx Success + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + // 3xx Redirection + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + // 4xx Client Error + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 413: 'Payload Too Large', + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 422: 'Unprocessable Entity', + 429: 'Too Many Requests', + // 5xx Server Error + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + }; + + return statusTexts[ status ] || ''; +} diff --git a/src/utils/fetch-interceptor.test.js b/src/utils/fetch-interceptor.test.js index b7004521..b431e09e 100644 --- a/src/utils/fetch-interceptor.test.js +++ b/src/utils/fetch-interceptor.test.js @@ -31,6 +31,7 @@ describe( 'initializeFetchInterceptor', () => { Promise.resolve( { ok: true, status: 200, + statusText: 'OK', headers: new Headers( { 'content-type': 'application/json', } ), @@ -76,6 +77,45 @@ describe( 'initializeFetchInterceptor', () => { expect( window.fetch ).toBe( currentFetch ); } ); + it( 'should derive statusText from status code when empty (HTTP/2)', async () => { + // Mock fetch with empty statusText (common with HTTP/2) + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + status: 201, + statusText: '', // Empty, as with HTTP/2 + headers: new Headers( { + 'content-type': 'application/json', + } ), + clone() { + return { + headers: this.headers, + text: () => Promise.resolve( '{}' ), + blob: () => + Promise.resolve( + new Blob( [ '{}' ], { + type: 'application/json', + } ) + ), + }; + }, + } ) + ); + + initializeFetchInterceptor(); + + await window.fetch( 'https://example.com/api', { method: 'POST' } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + status: 201, + statusText: 'Created', // Derived from status code + } ) + ); + } ); + describe( 'request header capture', () => { it( 'should capture headers from plain object with string URL', async () => { initializeFetchInterceptor(); @@ -98,6 +138,7 @@ describe( 'initializeFetchInterceptor', () => { Authorization: 'Bearer test-token', 'X-Custom-Header': 'custom-value', } ), + statusText: 'OK', } ) ); } );