diff --git a/CLAUDE.md b/CLAUDE.md index b891e52a..e41a97ea 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: @@ -186,12 +196,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-js +make fmt-js + +# 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. 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..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 } @@ -307,6 +312,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", @@ -403,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") @@ -609,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 } @@ -690,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 + } + } +} 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/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 3e3a7f8f..2ff51d06 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,36 @@ 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") + + // 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)}...") + } + } + }) 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) 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..1a27eaa4 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -182,6 +182,35 @@ 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") + + // 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))...") + } + } } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index eaf1a20b..6313b31b 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 { @@ -93,7 +97,9 @@ public struct EditorConfiguration: Sendable { editorSettings: editorSettings, locale: locale, isNativeInserterEnabled: isNativeInserterEnabled, - editorAssetsEndpoint: editorAssetsEndpoint + editorAssetsEndpoint: editorAssetsEndpoint, + logLevel: logLevel, + enableNetworkLogging: enableNetworkLogging ) } @@ -126,6 +132,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 +151,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 +171,7 @@ public struct EditorConfigurationBuilder { self.isNativeInserterEnabled = isNativeInserterEnabled self.editorAssetsEndpoint = editorAssetsEndpoint self.logLevel = logLevel + self.enableNetworkLogging = enableNetworkLogging } public func setTitle(_ title: String) -> EditorConfigurationBuilder { @@ -267,6 +276,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 +322,8 @@ public struct EditorConfigurationBuilder { locale: locale, isNativeInserterEnabled: isNativeInserterEnabled, editorAssetsEndpoint: editorAssetsEndpoint, - logLevel: logLevel + logLevel: logLevel, + enableNetworkLogging: enableNetworkLogging ) } } 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 e2af6fe4..6e06f4ae 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)); @@ -473,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 { fatalError("failed to decode message: \(error)") 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 + } +} 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" diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 2878a989..b14558da 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -172,6 +172,36 @@ 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 ) { + 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, + } ); + } +} + /** * @typedef GBKitConfig * @@ -182,6 +212,7 @@ export function onModalDialogClosed( dialogType ) { * @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. */ /** diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 4943e38c..597608a9 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -4,6 +4,7 @@ import { awaitGBKitGlobal, editorLoaded, getGBKit } from './bridge'; 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 './editor-styles'; @@ -32,6 +33,7 @@ export function setUpEditorEnvironment() { // - https://github.com/vitejs/vite/issues/13952 // - https://github.com/vitejs/vite/issues/5189#issuecomment-2175410148 return awaitGBKitGlobal() + .then( initializeFetchInterceptor ) .then( configureLocale ) .then( loadRemainingGlobals ) .then( initializeApiFetchWrapper ) diff --git a/src/utils/fetch-interceptor.js b/src/utils/fetch-interceptor.js new file mode 100644 index 00000000..5f456b31 --- /dev/null +++ b/src/utils/fetch-interceptor.js @@ -0,0 +1,313 @@ +/** + * Internal dependencies + */ +import { onNetworkRequest, getGBKit } from './bridge'; +import { debug } from './logger'; + +/** + * 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} + */ +export function initializeFetchInterceptor() { + // Don't initialize if already done + if ( window.__fetchInterceptorInitialized ) { + return; + } + + const config = getGBKit(); + + // Only override window.fetch if network logging is enabled + if ( ! config.enableNetworkLogging ) { + debug( 'Network logging disabled, fetch interceptor not initialized' ); + return; + } + + const originalFetch = window.fetch; + + window.fetch = async function ( 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 = serializeRequestBody( 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 = {}; + + try { + // Call original fetch + response = await originalFetch( input, init ); + + // Capture response metadata immediately + const responseClone = response.clone(); + responseStatus = response.status; + responseHeaders = serializeHeaders( response.headers ); + const duration = Math.round( performance.now() - startTime ); + + // 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 + 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' ); +} + +/** + * 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; + // 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 { + url, + method: method.toUpperCase(), + headers, + }; +} + +/** + * 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. + * + * @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 or plain object to a plain object. + * + * @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 new file mode 100644 index 00000000..b7004521 --- /dev/null +++ b/src/utils/fetch-interceptor.test.js @@ -0,0 +1,479 @@ +/** + * 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' ); + +// 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; + + beforeEach( () => { + // Reset window state + delete window.__fetchInterceptorInitialized; + + // 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', + } ) + ), + }; + }, + } ) + ); + + bridge.getGBKit.mockReturnValue( { + enableNetworkLogging: true, + } ); + bridge.onNetworkRequest = vi.fn(); + } ); + + afterEach( () => { + global.fetch = originalFetch; + vi.clearAllMocks(); + } ); + + 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 ); + } ); + + 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(); + + // 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, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( '[FormData with' ), + } ) + ); + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( + 'file= { + initializeFetchInterceptor(); + + const blob = new Blob( [ 'binary content' ], { + type: 'image/png', + } ); + + await window.fetch( 'https://example.com/upload', { + method: 'POST', + body: blob, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringMatching( + /\[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, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringMatching( + /\[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, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: '[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, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: '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, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: 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, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( + '[FormData with 4 field(s):' + ), + } ) + ); + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( + 'text=simple text value' + ), + } ) + ); + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: expect.stringContaining( + '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, + } ); + + await waitForAsyncLogging(); + + 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 }}` ) + ), + } ) + ); + } ); + + 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, + } ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: + '[ReadableStream - cannot serialize without consuming]', + } ) + ); + } ); + + it( 'should handle missing body gracefully', async () => { + initializeFetchInterceptor(); + + await window.fetch( 'https://example.com/api' ); + + await waitForAsyncLogging(); + + expect( bridge.onNetworkRequest ).toHaveBeenCalledWith( + expect.objectContaining( { + requestBody: null, + } ) + ); + } ); + } ); +} );