diff --git a/android/Gutenberg/build.gradle.kts b/android/Gutenberg/build.gradle.kts index d5b35ae5..18ab7928 100644 --- a/android/Gutenberg/build.gradle.kts +++ b/android/Gutenberg/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.jetbrains.kotlin.serialization) id("com.automattic.android.publish-to-s3") id("kotlin-parcelize") } @@ -42,6 +43,12 @@ android { kotlinOptions { jvmTarget = "1.8" } + + testOptions { + unitTests { + isReturnDefaultValues = true + } + } } dependencies { @@ -52,11 +59,16 @@ dependencies { implementation(libs.androidx.webkit) implementation(libs.gson) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.jsoup) + implementation(libs.okhttp) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) + testImplementation(libs.okhttp.mockwebserver) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/CachedAssetRequestInterceptor.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/CachedAssetRequestInterceptor.kt index 7456add3..aac3deda 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/CachedAssetRequestInterceptor.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/CachedAssetRequestInterceptor.kt @@ -3,10 +3,11 @@ package org.wordpress.gutenberg import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.util.Log +import org.wordpress.gutenberg.model.EditorAssetBundle import java.io.ByteArrayInputStream class CachedAssetRequestInterceptor( - private val library: EditorAssetsLibrary, + private val bundle: EditorAssetBundle, private val allowedHosts: Set = emptySet() ) : GutenbergRequestInterceptor { companion object { @@ -44,17 +45,14 @@ class CachedAssetRequestInterceptor( } // Handle asset caching - only serve if already cached - val cachedData = library.getCachedAsset(url) - if (cachedData != null) { + if (bundle.hasAssetData(url)) { + val cachedData = bundle.assetData(url) Log.d(TAG, "Serving cached asset: $url") return createResponse(url, cachedData) } - // Not cached - let WebView fetch normally and cache in background - Log.d(TAG, "Asset not cached, will cache in background: $url") - // Start background caching for next time - library.cacheAssetInBackground(url) - + // Not cached - let WebView fetch normally + Log.d(TAG, "Asset not cached: $url") return null // Let WebView handle the request normally } catch (e: Exception) { Log.e(TAG, "Error handling request: $url", e) @@ -62,10 +60,6 @@ class CachedAssetRequestInterceptor( } } - fun shutdown() { - library.shutdown() - } - private fun createResponse(url: String, data: ByteArray): WebResourceResponse { val mimeType = getMimeType(url) return WebResourceResponse( diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt deleted file mode 100644 index 9aea8503..00000000 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt +++ /dev/null @@ -1,240 +0,0 @@ -package org.wordpress.gutenberg - -import android.content.Context -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.net.HttpURLConnection -import java.net.URL -import java.security.MessageDigest - -class EditorAssetsLibrary( - private val context: Context, - private val configuration: EditorConfiguration -) { - private val cacheDir: File = getCacheDirectory() - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - companion object { - private const val TAG = "EditorAssetsLibrary" - private const val CACHE_CLEANUP_INTERVAL_DAYS = 7 - private val CACHEABLE_EXTENSIONS = setOf(".js", ".css", ".js.map") - } - - init { - if (!cacheDir.exists()) { - cacheDir.mkdirs() - } - } - - /** - * Loads the manifest content from the editor assets endpoint - */ - suspend fun loadManifestContent(headers: Map = emptyMap()): String = - withContext(Dispatchers.IO) { - val endpoint = configuration.editorAssetsEndpoint - ?: "${configuration.siteApiRoot}wpcom/v2/editor-assets" - - val connection = URL(endpoint).openConnection() as HttpURLConnection - try { - connection.requestMethod = "GET" - - // Set headers from configuration - if (configuration.authHeader.isNotEmpty()) { - connection.setRequestProperty("Authorization", configuration.authHeader) - } - - // Set headers from request - headers.forEach { (key, value) -> - connection.setRequestProperty(key, value) - } - - connection.connectTimeout = 30000 - connection.readTimeout = 30000 - - if (connection.responseCode in 200..299) { - connection.inputStream.use { it.bufferedReader().readText() } - } else { - throw Exception("Failed to fetch manifest: ${connection.responseCode}") - } - } finally { - connection.disconnect() - } - } - - /** - * Returns the manifest for use by the editor JavaScript - */ - suspend fun manifestContentForEditor(headers: Map = emptyMap()): String = - withContext(Dispatchers.IO) { - val manifestJson = loadManifestContent(headers) - - // Trigger periodic cache cleanup in background - // Assets are versioned, so old versions become unused naturally over time - scope.launch { - cleanupOldCache() - } - - manifestJson - } - - /** - * Caches a single asset from the given URL - */ - suspend fun cacheAsset(httpURL: String): File = withContext(Dispatchers.IO) { - if (!shouldCacheUrl(httpURL)) { - throw IllegalArgumentException("Unsupported URL for caching: $httpURL") - } - - val localFile = getLocalCacheFile(httpURL) - - // If already cached, return existing file (versioned URLs change when updated) - if (localFile.exists()) { - return@withContext localFile - } - - // Fetch and cache - val connection = URL(httpURL).openConnection() as HttpURLConnection - try { - connection.requestMethod = "GET" - connection.connectTimeout = 30000 - connection.readTimeout = 30000 - - if (connection.responseCode in 200..299) { - localFile.parentFile?.mkdirs() - connection.inputStream.use { input -> - localFile.outputStream().use { output -> - input.copyTo(output) - } - } - Log.d(TAG, "Cached asset: $httpURL (${localFile.length()} bytes)") - localFile - } else { - throw Exception("Failed to fetch asset: $httpURL (${connection.responseCode})") - } - } finally { - connection.disconnect() - } - } - - /** - * Gets cached asset data if available - */ - fun getCachedAsset(url: String): ByteArray? { - val localFile = getLocalCacheFile(url) - if (!localFile.exists()) { - return null - } - - return try { - localFile.readBytes() - } catch (e: Exception) { - Log.e(TAG, "Error reading cached asset: $url", e) - null - } - } - - fun clearCache() { - cacheDir.deleteRecursively() - cacheDir.mkdirs() - } - - /** - * Cache an asset in the background without blocking - */ - fun cacheAssetInBackground(url: String) { - if (!shouldCacheUrl(url)) return - - scope.launch { - try { - cacheAsset(url) - Log.d(TAG, "Background cached: $url") - } catch (e: Exception) { - Log.w(TAG, "Failed to background cache: $url", e) - } - } - } - - /** - * Cleans up old cached files to prevent unlimited storage growth. - * Since assets are versioned (URLs change when updated), old versions - * become unused naturally. We can safely remove files older than the - * cleanup interval without affecting functionality. - */ - private fun cleanupOldCache() { - try { - val cutoffTime = System.currentTimeMillis() - (CACHE_CLEANUP_INTERVAL_DAYS * 24 * 60 * 60 * 1000L) - var deletedCount = 0 - var deletedSize = 0L - - cacheDir.listFiles()?.forEach { file -> - if (file.isFile && file.lastModified() < cutoffTime) { - val size = file.length() - if (file.delete()) { - deletedCount++ - deletedSize += size - } - } - } - - if (deletedCount > 0) { - Log.d(TAG, "Cache cleanup: deleted $deletedCount files (${deletedSize / 1024}KB)") - } - } catch (e: Exception) { - Log.w(TAG, "Cache cleanup failed", e) - } - } - - fun shutdown() { - scope.cancel() - } - - private fun shouldCacheUrl(url: String): Boolean { - return url.startsWith("http") && CACHEABLE_EXTENSIONS.any { url.contains(it) } - } - - private fun getCacheDirectory(): File { - // Match iOS's directory structure - var siteName = "shared" - - if (configuration.siteURL.isNotEmpty()) { - try { - val url = URL(configuration.siteURL) - val host = url.host.replace(":", "-") - val path = url.path.replace("/", "-").trim('-') - siteName = if (path.isEmpty()) host else "$host-$path" - - // Remove illegal characters - siteName = siteName.replace(Regex("[/:\\\\?%*|\"<>]"), "-") - } catch (e: Exception) { - Log.w(TAG, "Failed to parse site URL for cache directory", e) - } - } - - return File(context.filesDir, "editor-caches/$siteName") - } - - private fun getLocalCacheFile(url: String): File { - // Generate unique filename similar to iOS - val path = URL(url).path.trimStart('/') - val nameWithoutExt = path.substringBeforeLast('.') - val extension = path.substringAfterLast('.', "") - - val hash = MessageDigest.getInstance("SHA-256") - .digest(url.toByteArray()) - .fold("") { str, it -> str + "%02x".format(it) } - - val filename = if (extension.isEmpty()) { - "$nameWithoutExt$hash" - } else { - "$nameWithoutExt$hash.$extension" - } - - return File(cacheDir, filename) - } -} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt new file mode 100644 index 00000000..4d18ec84 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -0,0 +1,258 @@ +package org.wordpress.gutenberg + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.wordpress.gutenberg.model.http.EditorHTTPHeaders +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * A protocol for making authenticated HTTP requests to the WordPress REST API. + */ +interface EditorHTTPClientProtocol { + suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse + suspend fun perform(method: String, url: String): EditorHTTPClientResponse +} + +/** + * A delegate for observing HTTP requests made by the editor. + * + * Implement this interface to inspect or log all network requests. + */ +interface EditorHTTPClientDelegate { + fun didPerformRequest(url: String, method: String, response: Response, data: ByteArray) +} + +/** + * Response from an HTTP request containing body data and response metadata. + */ +data class EditorHTTPClientResponse( + val data: ByteArray, + val statusCode: Int, + val headers: EditorHTTPHeaders +) { + val stringData: String + get() = data.toString(Charsets.UTF_8) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EditorHTTPClientResponse) return false + return data.contentEquals(other.data) && statusCode == other.statusCode && headers == other.headers + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + statusCode + result = 31 * result + headers.hashCode() + return result + } +} + +/** + * Response from a download request containing the downloaded file location and response metadata. + */ +data class EditorHTTPClientDownloadResponse( + val file: File, + val statusCode: Int, + val headers: EditorHTTPHeaders +) + +/** + * A WordPress REST API error response. + */ +data class WPError( + val code: String, + val message: String +) + +/** + * Errors that can occur during HTTP requests. + */ +sealed class EditorHTTPClientError : Exception() { + /** + * The server returned a WordPress-formatted error response. + */ + data class WPErrorResponse(val error: org.wordpress.gutenberg.WPError) : EditorHTTPClientError() { + override val message: String + get() = "${error.code}: ${error.message}" + } + + /** + * A file download failed with the given HTTP status code. + */ + data class DownloadFailed(val statusCode: Int) : EditorHTTPClientError() { + override val message: String + get() = "Download failed with status code: $statusCode" + } + + /** + * An unexpected error occurred with the given response data and status code. + */ + data class Unknown(val responseData: ByteArray, val statusCode: Int) : EditorHTTPClientError() { + override val message: String + get() = "Unknown error with status code: $statusCode" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Unknown) return false + return responseData.contentEquals(other.responseData) && statusCode == other.statusCode + } + + override fun hashCode(): Int { + var result = responseData.contentHashCode() + result = 31 * result + statusCode + return result + } + } +} + +/** + * An HTTP client for making authenticated requests to the WordPress REST API. + * + * This class handles request signing, error parsing, and response validation. + * All requests are automatically authenticated using the provided authorization header. + */ +class EditorHTTPClient( + private val authHeader: String, + private val delegate: EditorHTTPClientDelegate? = null, + private val requestTimeoutSeconds: Long = 60, + okHttpClient: OkHttpClient? = null +) : EditorHTTPClientProtocol { + + private val client: OkHttpClient = okHttpClient?.newBuilder() + ?.callTimeout(requestTimeoutSeconds, TimeUnit.SECONDS) + ?.build() + ?: OkHttpClient.Builder() + .callTimeout(requestTimeoutSeconds, TimeUnit.SECONDS) + .connectTimeout(requestTimeoutSeconds, TimeUnit.SECONDS) + .readTimeout(requestTimeoutSeconds, TimeUnit.SECONDS) + .writeTimeout(requestTimeoutSeconds, TimeUnit.SECONDS) + .build() + + override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(url) + .addHeader("Authorization", authHeader) + .get() + .build() + + val response = client.newCall(request).execute() + val statusCode = response.code + val headers = extractHeaders(response) + + if (statusCode !in 200..299) { + Log.e(TAG, "HTTP error downloading $url: $statusCode") + throw EditorHTTPClientError.DownloadFailed(statusCode) + } + + response.body?.let { body -> + destination.parentFile?.mkdirs() + destination.outputStream().use { output -> + body.byteStream().use { input -> + input.copyTo(output) + } + } + Log.d(TAG, "Downloaded file: file=${destination.absolutePath}, size=${destination.length()} bytes, url=$url") + } ?: throw EditorHTTPClientError.DownloadFailed(statusCode) + + EditorHTTPClientDownloadResponse( + file = destination, + statusCode = statusCode, + headers = headers + ) + } + + override suspend fun perform(method: String, url: String): EditorHTTPClientResponse = + withContext(Dispatchers.IO) { + // OkHttp requires a body for POST, PUT, PATCH methods + // GET, HEAD, OPTIONS, DELETE don't require a body + val requiresBody = method.uppercase() in listOf("POST", "PUT", "PATCH") + val requestBody = if (requiresBody) "".toRequestBody(null) else null + + val request = Request.Builder() + .url(url) + .addHeader("Authorization", authHeader) + .method(method, requestBody) + .build() + + val response = client.newCall(request).execute() + + // Note: This loads the entire response into memory. This is acceptable because + // this method is only used for WordPress REST API responses (editor settings, post + // data, themes, etc.) which are expected to be small (KB range). Large assets like + // JS/CSS files use the download() method which streams directly to disk. + val data = response.body?.bytes() ?: ByteArray(0) + val statusCode = response.code + val headers = extractHeaders(response) + + delegate?.didPerformRequest(url, method, response, data) + + if (statusCode !in 200..299) { + Log.e(TAG, "HTTP error fetching $url: $statusCode") + + // Try to parse as WordPress error + val wpError = tryParseWPError(data) + if (wpError != null) { + throw EditorHTTPClientError.WPErrorResponse(wpError) + } + + throw EditorHTTPClientError.Unknown(data, statusCode) + } + + EditorHTTPClientResponse( + data = data, + statusCode = statusCode, + headers = headers + ) + } + + private fun extractHeaders(response: Response): EditorHTTPHeaders { + val headerMap = mutableMapOf() + response.headers.forEach { (name, value) -> + headerMap[name] = value + } + return EditorHTTPHeaders(headerMap) + } + + private fun tryParseWPError(data: ByteArray): WPError? { + return try { + val json = data.toString(Charsets.UTF_8) + val parsed = gson.fromJson(json, WPErrorJson::class.java) + // Both code and message must be present (non-null) to be a valid WP error + // Empty strings are accepted to match Swift behavior + if (parsed.code != null && parsed.message != null) { + WPError(parsed.code, parsed.message) + } else { + null + } + } catch (e: JsonSyntaxException) { + null + } catch (e: Exception) { + null + } + } + + /** + * Internal data class for parsing WordPress error JSON responses. + */ + private data class WPErrorJson( + @SerializedName("code") val code: String?, + @SerializedName("message") val message: String?, + @SerializedName("data") val data: Any? = null + ) + + companion object { + private const val TAG = "EditorHTTPClient" + private val gson = Gson() + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt new file mode 100644 index 00000000..978cc5fc --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt @@ -0,0 +1,62 @@ +package org.wordpress.gutenberg + +import org.wordpress.gutenberg.model.EditorProgress + +/** + * Callback interface for monitoring editor loading state. + * + * Implement this interface to receive updates about the editor's loading progress, + * allowing you to display appropriate UI (progress bar, spinner, etc.) while the + * editor initializes. + * + * ## Loading Flow + * + * When dependencies are **not provided** to `GutenbergView.start()`: + * 1. `onDependencyLoadingStarted()` - Begin showing progress bar + * 2. `onDependencyLoadingProgress()` - Update progress bar (called multiple times) + * 3. `onDependencyLoadingFinished()` - Hide progress bar, show spinner + * 4. `onEditorReady()` - Hide spinner, editor is usable + * + * When dependencies **are provided** to `GutenbergView.start()`: + * 1. `onDependencyLoadingFinished()` - Show spinner (no progress phase) + * 2. `onEditorReady()` - Hide spinner, editor is usable + */ +interface EditorLoadingListener { + /** + * Called when dependency loading begins. + * + * This is the appropriate time to show a progress bar to the user. + * Only called when dependencies were not provided to `start()`. + */ + fun onDependencyLoadingStarted() + + /** + * Called periodically with progress updates during dependency loading. + * + * @param progress The current loading progress with completed/total counts. + */ + fun onDependencyLoadingProgress(progress: EditorProgress) + + /** + * Called when dependency loading completes. + * + * This is the appropriate time to hide the progress bar and show a spinner + * while the WebView loads and parses the editor JavaScript. + */ + fun onDependencyLoadingFinished() + + /** + * Called when the editor has fully loaded and is ready for use. + * + * This is the appropriate time to hide all loading indicators and reveal + * the editor. The editor APIs are safe to call after this callback. + */ + fun onEditorReady() + + /** + * Called if dependency loading fails. + * + * @param error The exception that caused the failure. + */ + fun onDependencyLoadingFailed(error: Throwable) +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergRequestInterceptor.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergRequestInterceptor.kt index 4bee1cd9..7f14912b 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergRequestInterceptor.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergRequestInterceptor.kt @@ -3,7 +3,7 @@ package org.wordpress.gutenberg import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse -public interface GutenbergRequestInterceptor { +interface GutenbergRequestInterceptor { fun canIntercept(request: WebResourceRequest): Boolean fun handleRequest(request: WebResourceRequest): WebResourceResponse? } @@ -16,4 +16,4 @@ class DefaultGutenbergRequestInterceptor: GutenbergRequestInterceptor { override fun handleRequest(request: WebResourceRequest): WebResourceResponse? { return null } -} \ No newline at end of file +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 6ea3abea..89fed83d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -3,13 +3,13 @@ package org.wordpress.gutenberg import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.util.Log -import android.view.View import android.view.inputmethod.InputMethodManager import android.webkit.ConsoleMessage import android.webkit.CookieManager @@ -22,23 +22,74 @@ import android.webkit.WebResourceResponse import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader.AssetsPathHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.GBKitGlobal +import org.wordpress.gutenberg.services.EditorService import java.util.Locale const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" +/** + * A WebView-based Gutenberg block editor for Android. + * + * ## Creating a GutenbergView + * + * This view must be created programmatically - XML layout inflation is not supported. + * Use the constructor directly or create it within a Compose `AndroidView`: + * + * ```kotlin + * // In an Activity or Fragment: + * val editor = GutenbergView( + * configuration = EditorConfiguration.builder(...).build(), + * dependencies = null, // or pre-fetched dependencies + * coroutineScope = lifecycleScope, + * context = this + * ) + * + * // In Jetpack Compose: + * AndroidView(factory = { context -> + * GutenbergView(configuration, dependencies, lifecycleScope, context) + * }) + * ``` + * + * ## Coroutine Scope Requirements + * + * The `coroutineScope` parameter is used for async operations like fetching editor + * dependencies. The caller owns this scope and is responsible for its lifecycle: + * + * - **Use a lifecycle-aware scope** (e.g., `lifecycleScope`, `viewModelScope`) + * to automatically cancel operations when the host is destroyed + * - The view does **not** cancel the scope in `onDetachedFromWindow()` + * - If using a custom scope, ensure it's cancelled when the editor is no longer needed + * + * ## Loading Behavior + * + * - If `dependencies` is provided, the editor loads immediately (fast path) + * - If `dependencies` is null, dependencies are fetched asynchronously before loading + */ class GutenbergView : WebView { private var isEditorLoaded = false private var didFireEditorLoaded = false private var assetLoader = WebViewAssetLoader.Builder() .addPathHandler("/assets/", AssetsPathHandler(this.context)) .build() - private var configuration: EditorConfiguration = EditorConfiguration.builder().build() + private val configuration: EditorConfiguration + private lateinit var dependencies: EditorDependencies private val handler = Handler(Looper.getMainLooper()) var filePathCallback: ValueCallback?>? = null @@ -56,6 +107,7 @@ class GutenbergView : WebView { private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null private var modalDialogStateListener: ModalDialogStateListener? = null private var networkRequestListener: NetworkRequestListener? = null + private var loadingListener: EditorLoadingListener? = null /** * Stores the contextId from the most recent openMediaLibrary call @@ -63,6 +115,8 @@ class GutenbergView : WebView { */ private var currentMediaContextId: String? = null + private val coroutineScope: CoroutineScope + var textEditorEnabled: Boolean = false set(value) { field = value @@ -112,21 +166,43 @@ class GutenbergView : WebView { editorDidBecomeAvailableListener = listener } - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super( - context, - attrs, - defStyle - ) + fun setEditorLoadingListener(listener: EditorLoadingListener?) { + loadingListener = listener + } + + /** + * Creates a new GutenbergView with the specified configuration. + * + * @param configuration The editor configuration specifying site details and capabilities. + * @param dependencies Pre-fetched editor dependencies, or null to fetch asynchronously. + * Providing dependencies enables instant editor loading. + * @param coroutineScope The scope for async operations. **Caller owns this scope** - + * use a lifecycle-aware scope like `lifecycleScope` to ensure + * operations are cancelled when the Activity/Fragment is destroyed. + * @param context The Android context. + */ + constructor(configuration: EditorConfiguration, dependencies: EditorDependencies?, coroutineScope: CoroutineScope, context: Context) : super(context) { + this.configuration = configuration + this.coroutineScope = coroutineScope + + if (dependencies != null) { + this.dependencies = dependencies + + // FAST PATH: Dependencies were provided - load immediately + loadEditor(dependencies) + } else { + // ASYNC FLOW: No dependencies - fetch them asynchronously + prepareAndLoadEditor() + } + } @SuppressLint("SetJavaScriptEnabled") // Without JavaScript we have no Gutenberg - fun initializeWebView() { + private fun initializeWebView() { this.settings.javaScriptCanOpenWindowsAutomatically = true this.settings.javaScriptEnabled = true this.settings.domStorageEnabled = true this.addJavascriptInterface(this, "editorDelegate") - this.visibility = View.GONE + this.visibility = GONE this.webViewClient = object : WebViewClient() { override fun onReceivedError( @@ -138,7 +214,7 @@ class GutenbergView : WebView { super.onReceivedError(view, request, error) } - override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) setGlobalJavaScriptVariables() } @@ -255,24 +331,63 @@ class GutenbergView : WebView { } } - fun start(configuration: EditorConfiguration) { - this.configuration = configuration + /** + * Fetches all required dependencies and then loads the editor. + * + * This method is the entry point for the async flow when no dependencies were provided. + */ + private fun prepareAndLoadEditor() { + loadingListener?.onDependencyLoadingStarted() - // Set up asset caching if enabled - if (configuration.enableAssetCaching) { - val library = EditorAssetsLibrary(context, configuration) - val cachedInterceptor = CachedAssetRequestInterceptor( - library, - configuration.cachedAssetHosts - ) - requestInterceptor = cachedInterceptor + Log.i("GutenbergView", "Fetching dependencies...") + + coroutineScope.launch { + Log.i("GutenbergView", "In coroutine scope") + Log.i("GutenbergView", "Fetching dependencies in IO context") + try { + val editorService = EditorService.create( + context = context, + configuration = configuration, + coroutineScope = coroutineScope + ) + Log.i("GutenbergView", "Created editor service") + val fetchedDependencies = editorService.prepare { progress -> + loadingListener?.onDependencyLoadingProgress(progress) + + Log.i("GutenbergView", "Progress: $progress") + } + + Log.i("GutenbergView", "Finished fetching dependencies") + + // Store dependencies and load the editor + loadEditor(fetchedDependencies) + } catch (e: Exception) { + Log.e("GutenbergView", "Failed to load dependencies", e) + loadingListener?.onDependencyLoadingFailed(e) + } } + } + + /** + * Loads the editor with the given dependencies. + * + * This is the shared loading path used by both flows after dependencies are available. + */ + private fun loadEditor(dependencies: EditorDependencies) { + this.dependencies = dependencies + + // Set up asset caching + requestInterceptor = CachedAssetRequestInterceptor( + dependencies.assetBundle, + configuration.cachedAssetHosts + ) + + // Notify that dependency loading is complete (spinner phase begins) + loadingListener?.onDependencyLoadingFinished() initializeWebView() - val editorUrl = if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { - BuildConfig.GUTENBERG_EDITOR_URL - } else { + val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { ASSET_URL } @@ -280,13 +395,13 @@ class GutenbergView : WebView { this.clearCache(true) // All cookies are third-party cookies because the root of this document // lives under `https://appassets.androidplatform.net` - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true); + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) // Erase all local cookies before loading the URL – we don't want to persist // anything between uses – otherwise we might send the wrong cookies CookieManager.getInstance().removeAllCookies { CookieManager.getInstance().flush() - for(cookie in configuration.cookies) { + for (cookie in configuration.cookies) { CookieManager.getInstance().setCookie(cookie.key, cookie.value) } this.loadUrl(editorUrl) @@ -296,38 +411,16 @@ class GutenbergView : WebView { } private fun setGlobalJavaScriptVariables() { - val escapedTitle = encodeForEditor(configuration.title) - val escapedContent = encodeForEditor(configuration.content) - val editorSettings = configuration.editorSettings ?: "undefined" - + val gbKit = GBKitGlobal.fromConfiguration(configuration, dependencies) + val gbKitJson = gbKit.toJsonString() val gbKitConfig = """ - window.GBKit = { - "siteApiRoot": "${configuration.siteApiRoot}", - "siteApiNamespace": ${configuration.siteApiNamespace.joinToString(",", "[", "]") { "\"$it\"" }}, - "namespaceExcludedPaths": ${configuration.namespaceExcludedPaths.joinToString(",", "[", "]") { "\"$it\"" }}, - "authHeader": "${configuration.authHeader}", - "themeStyles": ${configuration.themeStyles}, - "plugins": ${configuration.plugins}, - "hideTitle": ${configuration.hideTitle}, - "editorSettings": $editorSettings, - "locale": "${configuration.locale}", - ${if (configuration.editorAssetsEndpoint != null) "\"editorAssetsEndpoint\": \"${configuration.editorAssetsEndpoint}\"," else ""} - "enableNetworkLogging": ${configuration.enableNetworkLogging}, - "post": { - "id": ${configuration.postId ?: -1}, - "title": "$escapedTitle", - "content": "$escapedContent" - } - }; + window.GBKit = $gbKitJson; localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); """.trimIndent() this.evaluateJavascript(gbKitConfig, null) } - private fun encodeForEditor(value: String): String { - return java.net.URLEncoder.encode(value, "UTF-8").replace("+", "%20") - } fun clearConfig() { val jsCode = """ @@ -343,7 +436,7 @@ class GutenbergView : WebView { Log.e("GutenbergView", "You can't change the editor content until it has loaded") return } - val encodedContent = encodeForEditor(newContent) + val encodedContent = newContent.encodeForEditor() this.evaluateJavascript("editor.setContent('$encodedContent');", null) } @@ -352,7 +445,7 @@ class GutenbergView : WebView { Log.e("GutenbergView", "You can't change the editor content until it has loaded") return } - val encodedTitle = encodeForEditor(newTitle) + val encodedTitle = newTitle.encodeForEditor() this.evaluateJavascript("editor.setTitle('$encodedTitle');", null) } @@ -466,7 +559,7 @@ class GutenbergView : WebView { Log.e("GutenbergView", "You can't append text until the editor has loaded") return } - val encodedText = encodeForEditor(text) + val encodedText = text.encodeForEditor() handler.post { this.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) } @@ -478,9 +571,10 @@ class GutenbergView : WebView { isEditorLoaded = true handler.post { if(!didFireEditorLoaded) { + loadingListener?.onEditorReady() editorDidBecomeAvailableListener?.onEditorAvailable(this) this.didFireEditorLoaded = true - this.visibility = View.VISIBLE + this.visibility = VISIBLE this.alpha = 0f this.animate() .alpha(1f) @@ -703,17 +797,18 @@ class GutenbergView : WebView { super.onDetachedFromWindow() clearConfig() this.stopLoading() - (requestInterceptor as? CachedAssetRequestInterceptor)?.shutdown() FileCache.clearCache(context) contentChangeListener = null historyChangeListener = null featuredImageChangeListener = null editorDidBecomeAvailableListener = null + loadingListener = null filePathCallback = null onFileChooserRequested = null autocompleterTriggeredListener = null modalDialogStateListener = null networkRequestListener = null + requestInterceptor = DefaultGutenbergRequestInterceptor() handler.removeCallbacksAndMessages(null) this.destroy() } @@ -726,40 +821,6 @@ class GutenbergView : WebView { private var warmupRunnable: Runnable? = null private var warmupWebView: GutenbergView? = null - /** - * Warmup the editor by preloading assets in a temporary WebView. - * This pre-caches assets to improve editor launch speed. - */ - @JvmStatic - fun warmup(context: Context, configuration: EditorConfiguration) { - // Cancel any existing warmup - cancelWarmup() - - // Create dedicated warmup WebView - val webView = GutenbergView(context) - webView.initializeWebView() - webView.start(configuration) - warmupWebView = webView - - // Schedule cleanup after assets are loaded - warmupHandler = Handler(Looper.getMainLooper()) - warmupRunnable = Runnable { - cleanupWarmup() - } - warmupHandler?.postDelayed(warmupRunnable!!, ASSET_LOADING_TIMEOUT_MS) - } - - /** - * Cancel any pending warmup and clean up resources. - */ - @JvmStatic - fun cancelWarmup() { - warmupRunnable?.let { runnable -> - warmupHandler?.removeCallbacks(runnable) - } - cleanupWarmup() - } - /** * Clean up warmup resources. */ @@ -773,21 +834,6 @@ class GutenbergView : WebView { warmupHandler = null warmupRunnable = null } - - /** - * Create a new GutenbergView for the editor. - * Cancels any pending warmup to free resources. - */ - @JvmStatic - fun createForEditor(context: Context): GutenbergView { - // Cancel any pending warmup to free resources - cancelWarmup() - - // Create fresh WebView for editor - val webView = GutenbergView(context) - webView.initializeWebView() - return webView - } } } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/Paths.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/Paths.kt new file mode 100644 index 00000000..278c5720 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/Paths.kt @@ -0,0 +1,82 @@ +package org.wordpress.gutenberg + +import android.content.Context +import org.wordpress.gutenberg.model.EditorConfiguration +import java.io.File + +/** + * Utility object for constructing storage and cache directory paths. + */ +object Paths { + /** + * Returns the default storage root directory for GutenbergKit. + * + * This is typically in the app's files directory. + * + * @param context The Android context. + * @return The default storage root directory. + */ + fun defaultStorageRoot(context: Context): File { + return File(context.filesDir, "GutenbergKit") + } + + /** + * Returns the storage root directory for a specific site configuration. + * + * @param context The Android context. + * @param configuration The editor configuration. + * @return The site-specific storage directory. + */ + fun storageRoot(context: Context, configuration: EditorConfiguration): File { + return File(defaultStorageRoot(context), configuration.siteId) + } + + /** + * Returns the storage root directory for a specific site configuration. + * + * @param baseDir The base directory to use instead of context.filesDir. + * @param configuration The editor configuration. + * @return The site-specific storage directory. + */ + fun storageRoot(baseDir: File, configuration: EditorConfiguration): File { + return File(File(baseDir, "GutenbergKit"), configuration.siteId) + } + + fun defaultTempStorageRoot(context: Context): File { + return File(context.cacheDir, "GutenbergKit") + } + + /** + * Returns the default cache root directory for GutenbergKit. + * + * This is typically in the app's cache directory. + * + * @param context The Android context. + * @return The default cache root directory. + */ + fun defaultCacheRoot(context: Context): File { + return File(context.cacheDir, "GutenbergKit") + } + + /** + * Returns the cache root directory for a specific site configuration. + * + * @param context The Android context. + * @param configuration The editor configuration. + * @return The site-specific cache directory. + */ + fun cacheRoot(context: Context, configuration: EditorConfiguration): File { + return File(defaultCacheRoot(context), configuration.siteId) + } + + /** + * Returns the cache root directory for a specific site configuration. + * + * @param baseDir The base directory to use instead of context.cacheDir. + * @param configuration The editor configuration. + * @return The site-specific cache directory. + */ + fun cacheRoot(baseDir: File, configuration: EditorConfiguration): File { + return File(File(baseDir, "GutenbergKit"), configuration.siteId) + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt new file mode 100644 index 00000000..896a8570 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -0,0 +1,221 @@ +package org.wordpress.gutenberg + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorSettings +import org.wordpress.gutenberg.model.http.EditorHTTPHeaders +import org.wordpress.gutenberg.model.http.EditorHttpMethod +import org.wordpress.gutenberg.model.http.EditorURLResponse +import org.wordpress.gutenberg.stores.EditorURLCache + +/** + * A caching repository for WordPress REST API resources needed by the editor. + * + * `RESTAPIRepository` handles fetching and caching API responses such as editor settings, + * post data, theme information, and site options. Cached responses are stored on disk + * and returned on subsequent requests to improve loading performance. + */ +class RESTAPIRepository( + private val configuration: EditorConfiguration, + val httpClient: EditorHTTPClientProtocol, + private val cache: EditorURLCache +) { + private val json = Json { ignoreUnknownKeys = true } + + private val apiRoot = configuration.siteApiRoot.trimEnd('/') + private val editorSettingsUrl = "$apiRoot$EDITOR_SETTINGS_PATH" + private val activeThemeUrl = "$apiRoot$ACTIVE_THEME_PATH" + private val siteSettingsUrl = "$apiRoot$SITE_SETTINGS_PATH" + private val postTypesUrl = "$apiRoot$POST_TYPES_PATH" + + /** + * Cleanup any expired cache entries. + * + */ + fun cleanup() { + cache.clean() + } + + /** + * Clears all cached API responses. + */ + fun purge() { + cache.purge() + } + + // MARK: Post + + /** + * Fetches post data for the given post ID. + * + * @param id The post ID to fetch. + * @return The response containing the post data. + */ + suspend fun fetchPost(id: Int): EditorURLResponse { + val url = buildPostUrl(id) + val response = httpClient.perform("GET", url) + return EditorURLResponse( + data = response.stringData, + responseHeaders = response.headers + ) + } + + /** + * Reads cached post data for the given post ID. + * + * @param id The post ID to look up. + * @return The cached response, or `null` if not cached. + */ + fun readPost(id: Int): EditorURLResponse? { + return cache.getResponse(buildPostUrl(id), EditorHttpMethod.GET) + } + + private fun buildPostUrl(id: Int): String { + return "$apiRoot/wp/v2/posts/$id?context=edit" + } + + // MARK: Editor Settings + + /** + * Fetches editor settings from the WordPress REST API. + * + * Returns [EditorSettings.undefined] if plugins and theme styles are both disabled + * in the configuration. + * + * @return The parsed editor settings. + */ + suspend fun fetchEditorSettings(): EditorSettings { + if (!configuration.plugins && !configuration.themeStyles) { + return EditorSettings.undefined + } + + val response = httpClient.perform("GET", editorSettingsUrl) + val editorSettings = EditorSettings.fromData(response.stringData) + + // Store the parsed settings in cache + val urlResponse = EditorURLResponse( + data = json.encodeToString(editorSettings), + responseHeaders = response.headers + ) + cache.store(urlResponse, editorSettingsUrl, EditorHttpMethod.GET) + + return editorSettings + } + + /** + * Reads cached editor settings. + * + * @return The cached settings, or `null` if not cached. + */ + fun readEditorSettings(): EditorSettings? { + val response = cache.getResponse(editorSettingsUrl, EditorHttpMethod.GET) ?: return null + + return runCatching { + json.decodeFromString(response.data) + }.getOrNull() + } + + // MARK: GET Post Type + + /** + * Fetches the schema for a specific post type. + * + * @param type The post type slug (e.g., "post", "page"). + * @return The response containing the post type schema. + */ + internal suspend fun fetchPostType(type: String): EditorURLResponse { + return perform(EditorHttpMethod.GET, buildPostTypeUrl(type)) + } + + /** + * Reads cached post type schema. + * + * @param type The post type slug to look up. + * @return The cached response, or `null` if not cached. + */ + internal fun readPostType(type: String): EditorURLResponse? { + return cache.getResponse(buildPostTypeUrl(type), EditorHttpMethod.GET) + } + + private fun buildPostTypeUrl(type: String): String { + return "$apiRoot/wp/v2/types/$type?context=edit" + } + + // MARK: GET Active Theme + + /** + * Fetches the active theme information. + * + * @return The response containing the active theme data. + */ + internal suspend fun fetchActiveTheme(): EditorURLResponse { + return perform(EditorHttpMethod.GET, activeThemeUrl) + } + + /** + * Reads cached active theme information. + * + * @return The cached response, or `null` if not cached. + */ + internal fun readActiveTheme(): EditorURLResponse? { + return cache.getResponse(activeThemeUrl, EditorHttpMethod.GET) + } + + // MARK: OPTIONS Settings + + /** + * Fetches site settings options (OPTIONS request). + * + * @return The response containing the settings schema. + */ + internal suspend fun fetchSettingsOptions(): EditorURLResponse { + return perform(EditorHttpMethod.OPTIONS, siteSettingsUrl) + } + + /** + * Reads cached settings options. + * + * @return The cached response, or `null` if not cached. + */ + internal fun readSettingsOptions(): EditorURLResponse? { + return cache.getResponse(siteSettingsUrl, EditorHttpMethod.OPTIONS) + } + + // MARK: Post Types + + /** + * Fetches all available post types. + * + * @return The response containing the post types data. + */ + internal suspend fun fetchPostTypes(): EditorURLResponse { + return perform(EditorHttpMethod.GET, postTypesUrl) + } + + /** + * Reads cached post types. + * + * @return The cached response, or `null` if not cached. + */ + internal fun readPostTypes(): EditorURLResponse? { + return cache.getResponse(postTypesUrl, EditorHttpMethod.GET) + } + + private suspend fun perform(method: EditorHttpMethod, url: String): EditorURLResponse { + val response = httpClient.perform(method.name, url) + val urlResponse = EditorURLResponse( + data = response.stringData, + responseHeaders = response.headers + ) + cache.store(urlResponse, url, method) + return urlResponse + } + + companion object { + private const val EDITOR_SETTINGS_PATH = "/wp-block-editor/v1/settings" + private const val ACTIVE_THEME_PATH = "/wp/v2/themes?context=edit&status=active" + private const val SITE_SETTINGS_PATH = "/wp/v2/settings" + private const val POST_TYPES_PATH = "/wp/v2/types?context=view" + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt new file mode 100644 index 00000000..e042e911 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt @@ -0,0 +1,15 @@ +package org.wordpress.gutenberg + +import java.net.URLEncoder + +/** + * Encodes a string for safe injection into the editor's JavaScript. + * + * This performs URL encoding and replaces `+` with `%20` to ensure spaces + * are properly encoded for JavaScript string literals. + * + * @return The encoded string safe for editor injection. + */ +fun String.encodeForEditor(): String { + return URLEncoder.encode(this, "UTF-8").replace("+", "%20") +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAssetBundle.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAssetBundle.kt new file mode 100644 index 00000000..159ec86f --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAssetBundle.kt @@ -0,0 +1,230 @@ +package org.wordpress.gutenberg.model + +import android.util.Log +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.util.Date + +private const val TAG = "EditorAssetBundle" + +/** + * A collection of editor assets downloaded from a WordPress site. + * + * The asset bundle contains both the manifest (describing what assets are needed) + * and serves as the root directory for cached asset files. Assets are stored + * in a directory structure mirroring their original URL paths. + * + * ## Lifecycle + * + * 1. A bundle is created with a manifest from the server + * 2. Assets are downloaded and stored within the bundle's root directory + * 3. The bundle is persisted to disk with its metadata + * 4. On subsequent launches, the bundle can be loaded from disk and validated + * + * ## Identification + * + * Each bundle has an `id` derived from its manifest's checksum. This ensures that + * when a site's assets change, a new bundle will be created with a different ID, + * allowing old bundles to be cleaned up. + */ +data class EditorAssetBundle( + /** The parsed manifest describing the assets in this bundle. */ + val manifest: LocalEditorAssetManifest, + /** When this bundle was downloaded from the server. */ + val downloadDate: Date, + /** The root directory where this bundle's assets are stored. */ + val bundleRoot: File +) { + /** + * A unique identifier for this bundle based on its manifest content. + * + * Returns "empty" for bundles with empty manifests, otherwise returns + * the manifest's SHA-256 checksum. + */ + val id: String + get() = manifest.checksum + + /** + * The total number of assets (scripts + styles) that need to be cached. + */ + val assetCount: Int + get() = manifest.assetUrls.size + + /** + * A raw bundle structure for JSON serialization. + */ + @Serializable + data class RawAssetBundle( + val manifest: LocalEditorAssetManifest, + val downloadDate: Long // Unix timestamp in milliseconds + ) { + constructor(manifest: LocalEditorAssetManifest, downloadDate: Date) : this( + manifest = manifest, + downloadDate = downloadDate.time + ) + + fun toEditorAssetBundle(bundleRoot: File): EditorAssetBundle { + return EditorAssetBundle( + manifest = manifest, + downloadDate = Date(downloadDate), + bundleRoot = bundleRoot + ) + } + } + + companion object { + private val json = Json { ignoreUnknownKeys = true } + private const val MANIFEST_FILENAME = "manifest.json" + private const val EDITOR_REPRESENTATION_FILENAME = "editor-representation.json" + + /** + * An empty bundle for sites that don't support the editor-assets endpoint. + */ + val empty = EditorAssetBundle( + manifest = LocalEditorAssetManifest.empty, + downloadDate = Date(), + bundleRoot = File("/tmp/empty-bundle") + ) + + /** + * Loads a bundle from a manifest file URL. + * + * @param file The file containing the serialized bundle manifest. + * @throws Exception if the file cannot be read or parsed. + */ + fun fromFile(file: File): EditorAssetBundle { + val data = file.readText() + val rawBundle = json.decodeFromString(data) + return rawBundle.toEditorAssetBundle(file.parentFile ?: file) + } + } + + /** + * Creates a bundle with the current time as the download date. + */ + constructor(manifest: LocalEditorAssetManifest, bundleRoot: File) : this( + manifest = manifest, + downloadDate = Date(), + bundleRoot = bundleRoot + ) + + /** + * Checks if the asset data exists for a given URL. + * + * @param url The original asset URL. + * @return `true` if the asset has been cached locally. + */ + fun hasAssetData(url: String): Boolean { + return assetDataPath(url).exists() + } + + /** + * Returns the local file path where an asset should be stored. + * + * The path is constructed by appending the URL's path component to + * the bundle's root directory. + * + * Note: This method must not be called on [EditorAssetBundle.empty]. + * In debug builds, an assertion will catch this misuse. + * + * @param url The original asset URL. + * @return The file where the asset is (or should be) stored. + * @throws IllegalArgumentException if the URL path would escape the bundle root. + */ + fun assetDataPath(url: String): File { + assert(this != empty) { "Cannot get asset path from empty bundle" } + + val urlPath = java.net.URL(url).path + val resolvedFile = File(bundleRoot, urlPath).canonicalFile + val bundleRootCanonical = bundleRoot.canonicalFile + + require( + resolvedFile.path.startsWith(bundleRootCanonical.path + File.separator) || + resolvedFile.path == bundleRootCanonical.path + ) { + "Asset path escapes bundle root: $urlPath" + } + + return resolvedFile + } + + /** + * Reads the cached asset data for a given URL. + * + * @param url The original asset URL. + * @return The asset's binary data. + * @throws Exception if the asset has not been cached. + */ + fun assetData(url: String): ByteArray { + return assetDataPath(url).readBytes() + } + + /** + * Stores the editor representation for later retrieval. + * + * The editor representation is the processed manifest with rewritten URLs + * ready for injection into the WebView. + * + * @param representation The processed manifest to store. + */ + fun setEditorRepresentation(representation: RemoteEditorAssetManifest.RawManifest) { + val file = File(bundleRoot, EDITOR_REPRESENTATION_FILENAME) + val data = json.encodeToString(representation) + file.writeText(data) + Log.d(TAG, "Wrote editor representation: file=${file.absolutePath}, size=${data.length} bytes") + } + + /** + * Retrieves the stored editor representation. + * + * @return The stored representation. + * @throws Exception if no representation has been stored. + */ + fun getEditorRepresentation(): RemoteEditorAssetManifest.RawManifest { + val file = File(bundleRoot, EDITOR_REPRESENTATION_FILENAME) + val data = file.readText() + return json.decodeFromString(data) + } + + /** + * Retrieves the stored editor representation as a map for JSON serialization. + * + * @return A map representation suitable for JSON injection. + * @throws Exception if no representation has been stored. + */ + fun getEditorRepresentationAsMap(): Map { + val rep = getEditorRepresentation() + return mapOf( + "scripts" to rep.scripts, + "styles" to rep.styles, + "allowed_block_types" to rep.allowedBlockTypes + ) + } + + /** + * Saves the bundle's metadata to disk. + * + * Call this after downloading all assets to persist the bundle for future sessions. + */ + fun save() { + val rawBundle = RawAssetBundle(manifest = manifest, downloadDate = downloadDate) + val file = File(bundleRoot, MANIFEST_FILENAME) + val data = json.encodeToString(rawBundle) + file.writeText(data) + Log.d(TAG, "Wrote asset bundle manifest: file=${file.absolutePath}, size=${data.length} bytes") + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EditorAssetBundle) return false + return manifest == other.manifest && downloadDate == other.downloadDate + } + + override fun hashCode(): Int { + var result = manifest.hashCode() + result = 31 * result + downloadDate.hashCode() + return result + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorCachePolicy.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorCachePolicy.kt new file mode 100644 index 00000000..a18bff89 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorCachePolicy.kt @@ -0,0 +1,92 @@ +package org.wordpress.gutenberg.model + +import java.util.Date + +/** + * A policy that determines how cached responses should be validated and used. + * + * `EditorCachePolicy` provides three caching strategies that control when cached + * HTTP responses are considered valid. This is used by `EditorURLCache` to decide + * whether to return a cached response or require a fresh network request. + * + * ## Choosing a Policy + * + * - Use [Ignore] during development or when debugging to ensure fresh data. + * - Use [MaxAge] for production scenarios where data freshness matters. + * - Use [Always] for offline-first scenarios or when network availability is limited. + */ +sealed class EditorCachePolicy { + + /** + * Ignores the cache and always requires fresh data from the network. + * + * When this policy is active, all cached responses are considered invalid, + * forcing new network requests for every operation. + * + * Use this policy when: + * - Debugging caching issues + * - Testing network behavior + * - Data must always be fresh + */ + data object Ignore : EditorCachePolicy() + + /** + * Uses cached responses only if they are younger than the specified age. + * + * @property intervalMillis The maximum age in milliseconds for valid cached responses. + * + * Example usage: + * ```kotlin + * // Cache responses for up to 5 minutes + * val policy = EditorCachePolicy.MaxAge(300_000) + * + * // Cache responses for up to 1 hour + * val policy = EditorCachePolicy.MaxAge(3_600_000) + * + * // Cache responses for up to 1 day + * val policy = EditorCachePolicy.MaxAge(86_400_000) + * ``` + */ + data class MaxAge(val intervalMillis: Long) : EditorCachePolicy() + + /** + * Always uses cached responses regardless of their age. + * + * When this policy is active, any cached response is considered valid, + * no matter how old it is. This provides maximum cache utilization + * at the expense of data freshness. + * + * Use this policy when: + * - Operating in offline-first mode + * - Network connectivity is unreliable + * - Data freshness is less important than availability + */ + data object Always : EditorCachePolicy() + + /** + * Determines whether a cached response with the given storage date should be used. + * + * This method evaluates the cache policy against the provided dates to decide + * if a cached response is still valid. + * + * @param date The date when the response was originally cached. + * @param currentDate The current date to compare against. Defaults to now. + * + * @return `true` if the cached response should be used according to this policy, + * `false` if a fresh response should be fetched instead. + * + * ## Behavior by Policy + * + * - [Ignore]: Always returns `false` - cached responses are never used. + * - [MaxAge]: Returns `true` only if `date + interval > currentDate` + * (i.e., the cached response hasn't expired yet). + * - [Always]: Always returns `true` - cached responses are always used. + */ + fun allowsResponseWith(date: Date, currentDate: Date = Date()): Boolean { + return when (this) { + is Ignore -> false + is MaxAge -> Date(date.time + intervalMillis).after(currentDate) + is Always -> true + } + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt similarity index 70% rename from android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt rename to android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 83403f4e..c5a6cdc5 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -1,14 +1,17 @@ -package org.wordpress.gutenberg +package org.wordpress.gutenberg.model import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import java.net.URI +import java.util.UUID @Parcelize -open class EditorConfiguration constructor( +data class EditorConfiguration( val title: String, val content: String, val postId: Int?, - val postType: String?, + val postType: String, val themeStyles: Boolean, val plugins: Boolean, val hideTitle: Boolean, @@ -23,23 +26,40 @@ open class EditorConfiguration constructor( val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, - val enableNetworkLogging: Boolean = false + val enableNetworkLogging: Boolean = false, + var enableOfflineMode: Boolean = false, ): Parcelable { + + /** + * A site ID derived from the URL that can be used in file system paths. + * + * Returns the host portion of the siteURL, or a UUID if the URL has no host. + */ + @IgnoredOnParcel + val siteId: String by lazy { + try { + URI(siteURL).host ?: UUID.randomUUID().toString() + } catch (e: Exception) { + UUID.randomUUID().toString() + } + } companion object { @JvmStatic - fun builder(): Builder = Builder() + fun builder(siteURL: String, siteApiRoot: String, postType: String = "post"): Builder = Builder(siteURL, siteApiRoot, postType = postType) + + @JvmStatic + fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", "post") + .setEnableOfflineMode(true) + .build() } - class Builder { + class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: String) { private var title: String = "" private var content: String = "" private var postId: Int? = null - private var postType: String? = null private var themeStyles: Boolean = false private var plugins: Boolean = false private var hideTitle: Boolean = false - private var siteURL: String = "" - private var siteApiRoot: String = "" private var siteApiNamespace: Array = arrayOf() private var namespaceExcludedPaths: Array = arrayOf() private var authHeader: String = "" @@ -50,11 +70,12 @@ open class EditorConfiguration constructor( private var cachedAssetHosts: Set = emptySet() private var editorAssetsEndpoint: String? = null private var enableNetworkLogging: Boolean = false + private var enableOfflineMode: Boolean = false fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } fun setPostId(postId: Int?) = apply { this.postId = postId } - fun setPostType(postType: String?) = apply { this.postType = postType } + fun setPostType(postType: String) = apply { this.postType = postType } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins } fun setHideTitle(hideTitle: Boolean) = apply { this.hideTitle = hideTitle } @@ -70,6 +91,7 @@ open class EditorConfiguration constructor( fun setCachedAssetHosts(cachedAssetHosts: Set) = apply { this.cachedAssetHosts = cachedAssetHosts } fun setEditorAssetsEndpoint(editorAssetsEndpoint: String?) = apply { this.editorAssetsEndpoint = editorAssetsEndpoint } fun setEnableNetworkLogging(enableNetworkLogging: Boolean) = apply { this.enableNetworkLogging = enableNetworkLogging } + fun setEnableOfflineMode(enableOfflineMode: Boolean) = apply { this.enableOfflineMode = enableOfflineMode } fun build(): EditorConfiguration = EditorConfiguration( title = title, @@ -90,10 +112,35 @@ open class EditorConfiguration constructor( enableAssetCaching = enableAssetCaching, cachedAssetHosts = cachedAssetHosts, editorAssetsEndpoint = editorAssetsEndpoint, - enableNetworkLogging = enableNetworkLogging + enableNetworkLogging = enableNetworkLogging, + enableOfflineMode = enableOfflineMode ) } + /** + * Creates a new Builder pre-populated with all values from this configuration. + * + * This allows modifying specific fields while preserving others. + */ + fun toBuilder(): Builder = Builder(siteURL, siteApiRoot, postType) + .setTitle(title) + .setContent(content) + .setPostId(postId) + .setThemeStyles(themeStyles) + .setPlugins(plugins) + .setHideTitle(hideTitle) + .setSiteApiNamespace(siteApiNamespace) + .setNamespaceExcludedPaths(namespaceExcludedPaths) + .setAuthHeader(authHeader) + .setEditorSettings(editorSettings) + .setLocale(locale) + .setCookies(cookies) + .setEnableAssetCaching(enableAssetCaching) + .setCachedAssetHosts(cachedAssetHosts) + .setEditorAssetsEndpoint(editorAssetsEndpoint) + .setEnableNetworkLogging(enableNetworkLogging) + .setEnableOfflineMode(enableOfflineMode) + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -119,6 +166,8 @@ open class EditorConfiguration constructor( if (cachedAssetHosts != other.cachedAssetHosts) return false if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false if (enableNetworkLogging != other.enableNetworkLogging) return false + if (enableOfflineMode != other.enableOfflineMode) return false + if (siteId != other.siteId) return false return true } @@ -127,7 +176,7 @@ open class EditorConfiguration constructor( var result = title.hashCode() result = 31 * result + content.hashCode() result = 31 * result + (postId ?: 0) - result = 31 * result + (postType?.hashCode() ?: 0) + result = 31 * result + postType.hashCode() result = 31 * result + themeStyles.hashCode() result = 31 * result + plugins.hashCode() result = 31 * result + hideTitle.hashCode() @@ -143,6 +192,8 @@ open class EditorConfiguration constructor( result = 31 * result + cachedAssetHosts.hashCode() result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0) result = 31 * result + enableNetworkLogging.hashCode() + result = 31 * result + enableOfflineMode.hashCode() + result = 31 * result + siteId.hashCode() return result } } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorDependencies.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorDependencies.kt new file mode 100644 index 00000000..4f7c7086 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorDependencies.kt @@ -0,0 +1,35 @@ +package org.wordpress.gutenberg.model + +/** + * A collection of data fetched from the WordPress REST API required to initialize the editor. + * + * This class bundles together all the pre-fetched dependencies needed before the editor + * can be displayed. Fetching these dependencies ahead of time allows the editor to load + * quickly without blocking on network requests. + * + * Dependencies include: + * - Editor settings (theme styles, colors, typography, etc.) + * - Cached plugin/theme assets (JavaScript and CSS files) + * - Preloaded API responses (post types, taxonomies, etc.) + */ +data class EditorDependencies( + /** Configuration and styling information for the editor. */ + val editorSettings: EditorSettings, + /** Cached JavaScript and CSS assets for plugins and themes. */ + val assetBundle: EditorAssetBundle, + /** + * Pre-fetched API responses to avoid network requests during editor initialization. + * + * This is `null` if preloading is disabled or no preload data is available. + */ + val preloadList: EditorPreloadList? +) { + companion object { + /** Empty dependencies for use when no pre-fetched data is available. */ + val empty = EditorDependencies( + editorSettings = EditorSettings.undefined, + assetBundle = EditorAssetBundle.empty, + preloadList = null + ) + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorDependenciesSerializer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorDependenciesSerializer.kt new file mode 100644 index 00000000..8d33a56d --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorDependenciesSerializer.kt @@ -0,0 +1,107 @@ +package org.wordpress.gutenberg.model + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.util.UUID + +/** + * Handles serialization of [EditorDependencies] to and from disk. + * + * Since [EditorDependencies] contains non-serializable types like [File], + * this class provides methods to serialize dependencies to a temporary file + * and deserialize them back. This is useful for passing large dependency + * objects between activities without exceeding Intent size limits. + * + * ## Usage + * + * ```kotlin + * // In the sending activity: + * val filePath = EditorDependenciesSerializer.writeToDisk(context, dependencies) + * intent.putExtra("dependencies_path", filePath) + * + * // In the receiving activity: + * val filePath = intent.getStringExtra("dependencies_path") + * val dependencies = EditorDependenciesSerializer.readFromDisk(filePath) + * ``` + */ +object EditorDependenciesSerializer { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + /** + * Serializable representation of [EditorDependencies]. + * + * This class mirrors [EditorDependencies] but with serializable types: + * - [File] references are stored as path strings + * - [Date] objects are stored as Unix timestamps + */ + @Serializable + private data class SerializedDependencies( + val editorSettings: EditorSettings, + val assetBundleRaw: EditorAssetBundle.RawAssetBundle, + val assetBundleRootPath: String, + val preloadList: EditorPreloadList? + ) + + /** + * Writes [EditorDependencies] to a temporary file in the app's cache directory. + * + * @param context The Android context. + * @param dependencies The dependencies to serialize. + * @return The absolute path to the created file. + */ + fun writeToDisk(context: Context, dependencies: EditorDependencies): String { + val serialized = SerializedDependencies( + editorSettings = dependencies.editorSettings, + assetBundleRaw = EditorAssetBundle.RawAssetBundle( + manifest = dependencies.assetBundle.manifest, + downloadDate = dependencies.assetBundle.downloadDate + ), + assetBundleRootPath = dependencies.assetBundle.bundleRoot.absolutePath, + preloadList = dependencies.preloadList + ) + + val fileName = "editor_dependencies_${UUID.randomUUID()}.json" + val file = File(context.cacheDir, fileName) + file.writeText(json.encodeToString(serialized)) + + return file.absolutePath + } + + /** + * Reads [EditorDependencies] from a file and deletes the file. + * + * @param filePath The absolute path to the serialized dependencies file. + * @return The deserialized dependencies, or `null` if the file doesn't exist or is invalid. + */ + fun readFromDisk(filePath: String): EditorDependencies? { + val file = File(filePath) + if (!file.exists()) return null + + return try { + val data = file.readText() + val serialized = json.decodeFromString(data) + + val dependencies = EditorDependencies( + editorSettings = serialized.editorSettings, + assetBundle = serialized.assetBundleRaw.toEditorAssetBundle( + File(serialized.assetBundleRootPath) + ), + preloadList = serialized.preloadList + ) + + // Clean up the temp file + file.delete() + + dependencies + } catch (e: Exception) { + file.delete() + null + } + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt new file mode 100644 index 00000000..087e8269 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt @@ -0,0 +1,122 @@ +package org.wordpress.gutenberg.model + +import android.annotation.SuppressLint +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import org.wordpress.gutenberg.model.http.EditorURLResponse +import org.wordpress.gutenberg.model.http.asPreloadResponse + +/** + * Pre-fetched API responses that are injected into the editor to avoid network requests. + * + * The Gutenberg editor makes several API requests during initialization to fetch post types, + * theme data, and site settings. By pre-fetching these responses and injecting them as + * "preload data," the editor can initialize without waiting for network requests. + * + * The preload list is serialized to JSON and passed to the editor's JavaScript, which uses + * these cached responses instead of making network calls. + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +@ConsistentCopyVisibility +data class EditorPreloadList private constructor( + /** The ID of the post being edited, if editing an existing post. */ + val postID: Int?, + /** The pre-fetched post data for the post being edited. */ + val postData: EditorURLResponse?, + /** The post type identifier (e.g., "post", "page"). */ + val postType: String, + /** Pre-fetched data for the current post type's schema. */ + val postTypeData: EditorURLResponse, + /** Pre-fetched data for all available post types. */ + val postTypesData: EditorURLResponse, + /** Pre-fetched data for the active theme, if available. */ + val activeThemeData: EditorURLResponse?, + /** Pre-fetched site settings schema (OPTIONS request), if available. */ + val settingsOptionsData: EditorURLResponse? +) { + /** + * Creates a new preload list with the specified API responses. + * + * Response headers are filtered to only include headers relevant to preloading. + */ + constructor( + postID: Int? = null, + postData: EditorURLResponse? = null, + postType: String, + postTypeData: EditorURLResponse, + postTypesData: EditorURLResponse, + activeThemeData: EditorURLResponse?, + settingsOptionsData: EditorURLResponse?, + @Suppress("UNUSED_PARAMETER") filterHeaders: Boolean = true + ) : this( + postID = postID, + postData = postData?.asPreloadResponse(), + postType = postType, + postTypeData = postTypeData.asPreloadResponse(), + postTypesData = postTypesData.asPreloadResponse(), + activeThemeData = activeThemeData?.asPreloadResponse(), + settingsOptionsData = settingsOptionsData?.asPreloadResponse() + ) + + companion object { + private const val POST_TYPES_PATH = "/wp/v2/types?context=view" + private const val ACTIVE_THEME_PATH = "/wp/v2/themes?context=edit&status=active" + private const val SITE_SETTINGS_PATH = "/wp/v2/settings" + } + + fun build(): JsonElement { + val entries = mutableMapOf() + + entries[buildPostTypePath(postType)] = postTypeData.toJsonElement() + entries[POST_TYPES_PATH] = postTypesData.toJsonElement() + + if (postID != null && postData != null) { + entries[buildPostPath(postID)] = postData.toJsonElement() + } + + if (activeThemeData != null) { + entries[ACTIVE_THEME_PATH] = activeThemeData.toJsonElement() + } + + val optionsRequests = buildJsonObject { + if (settingsOptionsData != null) { + put(SITE_SETTINGS_PATH, settingsOptionsData.toJsonElement()) + } + } + entries["OPTIONS"] = optionsRequests + + // Sort keys alphabetically to match Swift output + return JsonObject(entries.toSortedMap()) + } + + /** + * Builds the preload list as a JSON string for injection into the editor. + * + * The JSON structure maps API paths to their cached responses, organized by HTTP method. + * GET requests are at the top level, while OPTIONS requests are nested under an "OPTIONS" key. + * + * @param formatted If `true`, returns pretty-printed JSON. Defaults to `false`. + * Formatting JSON is very expensive, so this shouldn't be used in production. + * @return A JSON string representing the preload data. + */ + fun build(formatted: Boolean = false): String { + val jsonElement = build() + return if (formatted) { + @Suppress("JSON_FORMAT_REDUNDANT") // This is only used in debug builds + Json { prettyPrint = true }.encodeToString(jsonElement) + } else { + Json.encodeToString(jsonElement) + } + } + + /** Builds the API path for fetching a specific post. */ + private fun buildPostPath(id: Int): String = "/wp/v2/posts/$id?context=edit" + + /** Builds the API path for fetching a post type's schema. */ + private fun buildPostTypePath(type: String): String = "/wp/v2/types/$type?context=edit" +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorProgress.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorProgress.kt new file mode 100644 index 00000000..60d7cecd --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorProgress.kt @@ -0,0 +1,42 @@ +package org.wordpress.gutenberg.model + +import android.annotation.SuppressLint +import kotlinx.serialization.Serializable +import kotlin.math.min + +/** + * Represents the progress of an editor loading operation. + * + * Used to report progress during asset downloads and other long-running operations. + * The progress is expressed as a count of completed items out of a total. + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class EditorProgress( + /** The number of items that have been completed. */ + val completed: Int, + /** The total number of items for the operation. */ + val total: Int +) { + /** + * The progress as a fraction between 0.0 and 1.0. + * + * Returns 0 if either `completed` or `total` is zero. + * The value is clamped to a maximum of 1.0. + */ + val fractionCompleted: Double + get() { + if (completed == 0 || total == 0) return 0.0 + return min(completed.toDouble() / total.toDouble(), 1.0) + } + + companion object { + /** A progress value representing no progress (0 of 0). */ + val zero = EditorProgress(completed = 0, total = 0) + } +} + +/** + * A callback that receives progress updates during long-running operations. + */ +typealias EditorProgressCallback = suspend (EditorProgress) -> Unit diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorSettings.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorSettings.kt new file mode 100644 index 00000000..b35b7e23 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorSettings.kt @@ -0,0 +1,95 @@ +package org.wordpress.gutenberg.model + +import android.annotation.SuppressLint +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * Editor configuration and styling data fetched from the WordPress REST API. + * + * This class wraps the raw JSON response from the block editor settings endpoint, + * which contains theme colors, typography, spacing, and other customization options. + * The raw JSON is injected directly into the editor's JavaScript. + * + * Theme styles are extracted separately for use in styling the editor UI. + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class EditorSettings( + val jsonValue: JsonElement? = null, + /** + * CSS styles extracted from the theme's global styles. + * + * Contains the concatenated CSS from all theme style entries. + * Used to apply theme styling to the editor interface. + */ + val themeStyles: String = "", + val stringValue: String = "" +) { + companion object { + @Transient + private val json = Json { ignoreUnknownKeys = true } + + /** + * Creates editor settings from raw API response data. + * + * Parses the JSON to extract theme styles while preserving the raw string + * for JavaScript injection. This operation involves JSON parsing and should + * be cached. + * + * @param data The raw JSON string from the block editor settings endpoint. + * Must be valid JSON (empty or blank strings will throw). + * @throws kotlinx.serialization.SerializationException if [data] is not valid JSON, + * including empty or blank strings. + */ + fun fromData(data: String): EditorSettings { + val jsonValue = json.parseToJsonElement(data) + val stringValue = jsonValue.toString().orEmpty() + + val themeStyles = runCatching { + json.decodeFromString(data) + .styles + .mapNotNull { it.css } + .joinToString("\n") + }.getOrDefault("") + + return EditorSettings( + jsonValue = jsonValue, + themeStyles = themeStyles, + stringValue = stringValue + ) + } + + /** A placeholder value for when editor settings are not available. */ + val undefined = EditorSettings( + jsonValue = null, + themeStyles = "undefined", + stringValue = "" + ) + } +} + +/** + * Internal structure for decoding the editor settings response. + * + * Only decodes the fields needed to extract theme styles. + */ +@Serializable +internal data class InternalEditorSettings( + val styles: List = emptyList() +) + +/** + * A CSS style entry from the theme. + */ +@Serializable +internal data class CSSStyle( + /** The CSS content, if present. */ + val css: String? = null, + /** Whether this style is from the global styles system. */ + @SerialName("isGlobalStyles") + val isGlobalStyles: Boolean = false +) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt new file mode 100644 index 00000000..7193ae0d --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -0,0 +1,121 @@ +package org.wordpress.gutenberg.model + +import android.annotation.SuppressLint +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.wordpress.gutenberg.encodeForEditor + +/** + * Configuration object passed to the editor's JavaScript as a global variable. + * + * This class is serialized to JSON and injected into the WebView as `window.GBKit`, + * providing the JavaScript code with all the information it needs to initialize + * the editor and communicate with the WordPress REST API. + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class GBKitGlobal( + /** The site's base URL, or `null` if offline mode is enabled. */ + val siteURL: String?, + /** The WordPress REST API root URL, or `null` if offline mode is enabled. */ + val siteApiRoot: String?, + /** + * Namespace segments to insert into API request paths. + * + * Used primarily for WordPress.com sites where API requests need a site identifier. + * The first namespace is inserted after the first two path segments for eligible requests. + * + * For example, with `["sites/123"]`, a request to `/wp/v2/posts` becomes `/wp/v2/sites/123/posts`. + * + * All namespaces are used to detect whether a path already contains a namespace (to avoid + * double-insertion), but only the first one is used for insertion. + */ + val siteApiNamespace: List, + /** + * API paths that should not have the namespace inserted. + * + * Paths starting with any of these prefixes will bypass the namespace insertion middleware. + */ + val namespaceExcludedPaths: List, + /** The authorization header value for authenticated API requests. */ + val authHeader: String, + /** Whether to apply theme styles to the editor. */ + val themeStyles: Boolean, + /** Whether to load plugin assets. */ + val plugins: Boolean, + /** Whether to use the native block inserter instead of the web-based one. */ + val enableNativeBlockInserter: Boolean = false, + /** Whether to hide the post title field in the editor. */ + val hideTitle: Boolean, + /** The locale identifier for translations (e.g., `fr`). */ + val locale: String, + /** The post being edited. */ + val post: Post, + /** The logging level for JavaScript console output. */ + val logLevel: String = "warn", + /** Whether to log network requests in the JavaScript console. */ + val enableNetworkLogging: Boolean, + /** The raw editor settings JSON from the WordPress REST API. */ + val editorSettings: JsonElement?, + /** Pre-fetched API responses JSON for faster editor initialization. */ + val preloadData: JsonElement? = null +) { + /** + * The post data passed to the editor. + */ + @Serializable + data class Post( + /** The post ID, or -1 for new posts. */ + val id: Int, + /** The post title (URL-encoded). */ + val title: String, + /** The post content (URL-encoded Gutenberg block markup). */ + val content: String + ) + + companion object { + private val json = Json { encodeDefaults = true } + + /** + * Creates a global configuration from an editor configuration and dependencies. + * + * @param configuration The editor configuration. + * @param dependencies The pre-fetched editor dependencies. + */ + fun fromConfiguration( + configuration: EditorConfiguration, + dependencies: EditorDependencies? + ): GBKitGlobal { + return GBKitGlobal( + siteURL = configuration.siteURL.ifEmpty { null }, + siteApiRoot = configuration.siteApiRoot.ifEmpty { null }, + siteApiNamespace = configuration.siteApiNamespace.toList(), + namespaceExcludedPaths = configuration.namespaceExcludedPaths.toList(), + authHeader = configuration.authHeader, + themeStyles = configuration.themeStyles, + plugins = configuration.plugins, + hideTitle = configuration.hideTitle, + locale = configuration.locale ?: "en", + post = Post( + id = configuration.postId ?: -1, + title = configuration.title.encodeForEditor(), + content = configuration.content.encodeForEditor() + ), + enableNetworkLogging = configuration.enableNetworkLogging, + editorSettings = dependencies?.editorSettings?.jsonValue, + preloadData = dependencies?.preloadList?.build() + ) + } + } + + /** + * Serializes the configuration to a JSON string for injection into JavaScript. + * + * @return A JSON string representation of this object. + */ + fun toJsonString(): String { + return json.encodeToString(this) + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/LocalEditorAssetManifest.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/LocalEditorAssetManifest.kt new file mode 100644 index 00000000..5e84584a --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/LocalEditorAssetManifest.kt @@ -0,0 +1,174 @@ +package org.wordpress.gutenberg.model + +import android.annotation.SuppressLint +import android.net.Uri +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup + +/** + * A processed editor asset manifest with parsed URLs ready for downloading. + * + * This class transforms a `RemoteEditorAssetManifest` by parsing the raw HTML + * to extract individual script and stylesheet URLs. It preserves the original + * HTML for later injection into the editor WebView. + * + * The manifest is used to: + * 1. Determine which assets need to be downloaded and cached + * 2. Provide the raw HTML for rendering in the editor (with URL rewriting) + * 3. Specify which block types are allowed for this site + */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class LocalEditorAssetManifest( + /** URLs of all external scripts that need to be cached locally. */ + val scripts: List, + /** URLs of all external stylesheets that need to be cached locally. */ + val styles: List, + /** The block type identifiers that can be used in the editor (e.g., "core/paragraph"). */ + val allowedBlockTypes: List, + /** The original HTML containing `", + "styles": "", + "allowed_block_types": ["core/paragraph", "core/heading"] + } + """.trimIndent() + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + + assertEquals(listOf("core/paragraph", "core/heading"), manifest.allowedBlockTypes) + assertTrue(manifest.rawScripts.contains("plugin.js")) + assertTrue(manifest.rawStyles.contains("plugin.css")) + } + + @Test + fun `fetchManifest with ignore cache policy always fetches new data`() = runBlocking { + val manifestJSON = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": ["core/paragraph"] + } + """.trimIndent() + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + library.fetchManifest() + library.fetchManifest() + + assertEquals(2, mockClient.getCallCount) + } + + @Test + fun `fetchManifest with always cache policy returns cached manifest when bundle exists on disk`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-cached-manifest-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Always) + + // First, fetch the manifest and create a bundle on disk + val originalManifest = library.fetchManifest() + library.buildBundle(originalManifest) + + // Now fetch again - should return the on-disk manifest + val cachedManifest = library.fetchManifest() + + // The checksums should match since it's the same manifest data + assertEquals(cachedManifest.checksum, originalManifest.checksum) + + // Verify we made 2 HTTP calls (one for each fetchManifest) + assertEquals(2, mockClient.getCallCount) + } + + @Test + fun `fetchManifest with always cache policy falls back to new manifest when no bundle exists`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-no-cache-fallback-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Always) + + // Fetch with always cache policy when no bundle exists on disk + val manifest = library.fetchManifest() + + // Should still return a valid manifest (created from remote data) + assertTrue(manifest.checksum.isNotEmpty()) + assertEquals(1, mockClient.getCallCount) + } + + // MARK: - readAssetBundles Tests + + @Test + fun `readAssetBundles returns empty list when no bundles directory exists`() { + val library = makeLibrary() + assertTrue(library.readAssetBundles().isEmpty()) + } + + @Test + fun `readAssetBundles returns empty list when directory exists but has no bundles`() { + val library = makeLibrary() + + // Create the storage root directory without any bundles + storageRoot.mkdirs() + + val bundles = library.readAssetBundles() + assertTrue(bundles.isEmpty()) + } + + @Test + fun `readAssetBundles returns single bundle after buildBundle`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-single-bundle-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + val createdBundle = library.buildBundle(manifest) + + val bundles = library.readAssetBundles() + + assertEquals(1, bundles.size) + assertEquals(bundles.first().id, createdBundle.id) + } + + @Test + fun `readAssetBundles returns multiple bundles sorted by download date`() = runBlocking { + val mockClient = EditorAssetsLibraryMockHTTPClient() + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + // Create first bundle + val manifest1JSON = uniqueManifestJSON("test-multi-bundle-1-${UUID.randomUUID()}") + mockClient.getResponse = manifest1JSON.toByteArray() + + val manifest1 = library.fetchManifest() + val bundle1 = library.buildBundle(manifest1) + + // Small delay to ensure different download dates + Thread.sleep(10) + + // Create second bundle + val manifest2JSON = uniqueManifestJSON("test-multi-bundle-2-${UUID.randomUUID()}") + mockClient.getResponse = manifest2JSON.toByteArray() + + val manifest2 = library.fetchManifest() + val bundle2 = library.buildBundle(manifest2) + + val bundles = library.readAssetBundles() + + assertEquals(2, bundles.size) + + // Should be sorted newest to oldest (descending by downloadDate) + assertEquals(bundles[0].id, bundle2.id) + assertEquals(bundles[1].id, bundle1.id) + assertTrue(bundles[0].downloadDate > bundles[1].downloadDate) + } + + @Test + fun `readAssetBundles ignores non-directory files in site root`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-ignores-files-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + library.buildBundle(manifest) + + // Add a non-directory file to the storage root + val randomFile = File(storageRoot, "random-file.txt") + randomFile.writeText("random content") + + val bundles = library.readAssetBundles() + + // Should only return the actual bundle, not the random file + assertEquals(1, bundles.size) + } + + @Test + fun `readAssetBundles returns bundles with correct manifest data`() = runBlocking { + val blockTypes = listOf("core/paragraph", "core/heading", "core/image") + val manifestJSON = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": ["core/paragraph", "core/heading", "core/image"] + } + """.trimIndent() + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + library.buildBundle(manifest) + + val bundles = library.readAssetBundles() + + assertEquals(1, bundles.size) + + val retrievedBundle = bundles[0] + assertEquals(blockTypes, retrievedBundle.manifest.allowedBlockTypes) + } + + // MARK: - buildBundle Tests + + @Test + fun `buildBundle returns bundle for manifest with no assets`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-empty-bundle-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + + val bundle = library.buildBundle(manifest) + + assertEquals(bundle.manifest.checksum, manifest.checksum) + assertTrue(bundle.id.isNotEmpty()) + assertEquals(0, mockClient.downloadCallCount) + } + + @Test + fun `buildBundle downloads all script and style assets`() = runBlocking { + val manifestJSON = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": ["core/paragraph"] + } + """.trimIndent() + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + + val progressTracker = ProgressTracker() + + library.buildBundle(manifest) { progress -> + progressTracker.append(progress) + } + + assertEquals(3, mockClient.downloadCallCount) + + // Should have downloaded 3 assets (2 scripts + 1 style) + assertTrue(mockClient.downloadedURLs.contains("https://example.com/script1.js")) + assertTrue(mockClient.downloadedURLs.contains("https://example.com/script2.js")) + assertTrue(mockClient.downloadedURLs.contains("https://example.com/style.css")) + + // Progress should have been reported for each asset + assertEquals(3, progressTracker.count) + } + + @Test + fun `buildBundle reports progress correctly`() = runBlocking { + val manifestJSON = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + + val progressTracker = ProgressTracker() + library.buildBundle(manifest) { progress -> + progressTracker.append(progress) + } + + // Should have 2 progress updates + assertEquals(2, progressTracker.count) + + // All updates should have total == 2 + for (progress in progressTracker.updates) { + assertEquals(2, progress.total) + } + + // Final progress should be complete + val lastProgress = progressTracker.updates.lastOrNull() + assertNotNull(lastProgress) + assertEquals(1.0, lastProgress!!.fractionCompleted, 0.0) + } + + // MARK: - downloadAssetBundle Tests + + @Test + fun `downloadAssetBundle fetches manifest and builds bundle`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-download-bundle-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient) + + val bundle = library.downloadAssetBundle() + + assertTrue(bundle.id.isNotEmpty()) + assertEquals(1, mockClient.getCallCount) // One call for the manifest + } + + @Test + fun `downloadAssetBundle reports progress`() = runBlocking { + val manifestJSON = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient) + + val progressTracker = ProgressTracker() + library.downloadAssetBundle { progress -> + progressTracker.append(progress) + } + + assertEquals(1, progressTracker.count) + assertEquals(1, progressTracker.updates.first().total) + } + + // MARK: - fetchManifest with Real Manifest Data Tests + + @Test + fun `fetchManifest parses real manifest test case with many block types`() = runBlocking { + val manifestData = TestResources.loadResource("editor-asset-manifest-test-case-1.json") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestData.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + + // Verify block types are parsed correctly + assertTrue(manifest.allowedBlockTypes.contains("core/paragraph")) + assertTrue(manifest.allowedBlockTypes.contains("core/heading")) + assertTrue(manifest.allowedBlockTypes.contains("core/image")) + assertTrue(manifest.allowedBlockTypes.contains("jetpack/ai-assistant")) + assertTrue(manifest.allowedBlockTypes.size > 100) + + // Verify scripts are present + assertTrue(manifest.rawScripts.contains("wp-polyfill")) + assertTrue(manifest.rawScripts.contains("jquery")) + assertTrue(manifest.rawScripts.contains("react")) + } + + @Test + fun `fetchManifest generates consistent checksum for same data`() = runBlocking { + val manifestData = TestResources.loadResource("editor-asset-manifest-test-case-1.json") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestData.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest1 = library.fetchManifest() + val manifest2 = library.fetchManifest() + + assertEquals(manifest1.checksum, manifest2.checksum) + assertTrue(manifest1.checksum.isNotEmpty()) + } + + // MARK: - EditorAssetBundle Tests + + @Test + fun `EditorAssetBundle can be created from manifest`() = runBlocking { + val manifestData = TestResources.loadResource("editor-asset-manifest-test-case-1.json") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestData.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + + val bundleRoot = tempFolder.newFolder("bundle") + val bundle = EditorAssetBundle(manifest = manifest, bundleRoot = bundleRoot) + + assertEquals(bundle.id, manifest.checksum) + assertEquals(bundle.manifest.allowedBlockTypes, manifest.allowedBlockTypes) + } + + @Test + fun `EditorAssetBundle downloadDate is set on creation`() { + val beforeCreation = java.util.Date() + + val bundleRoot = tempFolder.newFolder("bundle") + val bundle = EditorAssetBundle(manifest = LocalEditorAssetManifest.empty, bundleRoot = bundleRoot) + + val afterCreation = java.util.Date() + + assertTrue(bundle.downloadDate >= beforeCreation) + assertTrue(bundle.downloadDate <= afterCreation) + } + + @Test + fun `Multiple bundles from same manifest have same ID`() = runBlocking { + val manifestData = TestResources.loadResource("editor-asset-manifest-test-case-1.json") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestData.toByteArray() + + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + val manifest = library.fetchManifest() + + val bundleRoot1 = tempFolder.newFolder("bundle1") + val bundleRoot2 = tempFolder.newFolder("bundle2") + val bundle1 = EditorAssetBundle(manifest = manifest, bundleRoot = bundleRoot1) + val bundle2 = EditorAssetBundle(manifest = manifest, bundleRoot = bundleRoot2) + + assertEquals(bundle1.id, bundle2.id) + } + + // MARK: - CachePolicy Tests + + @Test + fun `EditorCachePolicy always is default behavior`() = runBlocking { + val manifestJSON = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + val library = makeLibrary(httpClient = mockClient) + + // Call fetchManifest with default cache policy + library.fetchManifest() + + // The HTTP client should have been called + assertEquals(1, mockClient.getCallCount) + } + + @Test + fun `EditorCachePolicy maxAge uses cached manifest when within timeout`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-maxage-within-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + // Set maxAge to 1 hour (3600000 milliseconds) + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.MaxAge(3_600_000)) + + // First fetch and create the bundle + val originalManifest = library.fetchManifest() + library.buildBundle(originalManifest) + + // Second fetch should use cached manifest since we're within the 1 hour timeout + val cachedManifest = library.fetchManifest() + + assertEquals(cachedManifest.checksum, originalManifest.checksum) + // Should have made 2 HTTP calls but second one used cached bundle + assertEquals(2, mockClient.getCallCount) + } + + @Test + fun `EditorCachePolicy maxAge fetches new manifest when timeout expired`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-maxage-expired-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + // Set maxAge to 0 milliseconds (immediately expired) + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.MaxAge(0)) + + // First fetch and create the bundle + val originalManifest = library.fetchManifest() + library.buildBundle(originalManifest) + + // Second fetch should NOT use cached manifest since maxAge(0) means immediately expired + val newManifest = library.fetchManifest() + + // The checksums should still match (same data) but the cache was bypassed + assertEquals(newManifest.checksum, originalManifest.checksum) + assertEquals(2, mockClient.getCallCount) + } + + @Test + fun `EditorCachePolicy maxAge with short timeout expires after delay`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-maxage-delay-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + // Set maxAge to 50 milliseconds + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.MaxAge(50)) + + // First fetch and create the bundle + val originalManifest = library.fetchManifest() + library.buildBundle(originalManifest) + + // Wait for the cache to expire + Thread.sleep(100) + + // Third fetch should bypass cache since it's expired + library.fetchManifest() + + // Both fetches should have made HTTP calls since cache expired + assertEquals(2, mockClient.getCallCount) + } + + @Test + fun `EditorCachePolicy maxAge uses cache before expiry then fetches after`() = runBlocking { + val manifestJSON = uniqueManifestJSON("test-maxage-transition-${UUID.randomUUID()}") + + val mockClient = EditorAssetsLibraryMockHTTPClient() + mockClient.getResponse = manifestJSON.toByteArray() + + // Set maxAge to 100 milliseconds + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.MaxAge(100)) + + // First fetch and create the bundle + val originalManifest = library.fetchManifest() + library.buildBundle(originalManifest) + + // Immediate second fetch should use cache (within 100ms) + val cachedManifest = library.fetchManifest() + assertEquals(cachedManifest.checksum, originalManifest.checksum) + + // Wait for cache to expire + Thread.sleep(150) + + // Third fetch should create new manifest since cache expired + val newManifest = library.fetchManifest() + assertEquals(newManifest.checksum, originalManifest.checksum) + + // Should have made 3 HTTP calls total + assertEquals(3, mockClient.getCallCount) + } + + // MARK: - cleanup and purge Tests + + @Test + fun `cleanup removes all bundles except the most recent`() = runBlocking { + val mockClient = EditorAssetsLibraryMockHTTPClient() + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + // Create first bundle + mockClient.getResponse = uniqueManifestJSON("bundle-1-${UUID.randomUUID()}").toByteArray() + val manifest1 = library.fetchManifest() + library.buildBundle(manifest1) + + Thread.sleep(10) + + // Create second bundle + mockClient.getResponse = uniqueManifestJSON("bundle-2-${UUID.randomUUID()}").toByteArray() + val manifest2 = library.fetchManifest() + library.buildBundle(manifest2) + + assertEquals(2, library.readAssetBundles().size) + + library.cleanup() + + val remainingBundles = library.readAssetBundles() + assertEquals(1, remainingBundles.size) + assertEquals(manifest2.checksum, remainingBundles[0].id) + } + + @Test + fun `purge removes all bundles`() = runBlocking { + val mockClient = EditorAssetsLibraryMockHTTPClient() + val library = makeLibrary(httpClient = mockClient, cachePolicy = EditorCachePolicy.Ignore) + + // Create bundles + mockClient.getResponse = uniqueManifestJSON("bundle-1-${UUID.randomUUID()}").toByteArray() + library.buildBundle(library.fetchManifest()) + + mockClient.getResponse = uniqueManifestJSON("bundle-2-${UUID.randomUUID()}").toByteArray() + library.buildBundle(library.fetchManifest()) + + assertEquals(2, library.readAssetBundles().size) + + library.purge() + + assertTrue(library.readAssetBundles().isEmpty()) + } +} + +// MARK: - Progress Tracker for Tests + +class ProgressTracker { + private val _updates = CopyOnWriteArrayList() + + val updates: List + get() = _updates.toList() + + val count: Int + get() = _updates.size + + fun append(progress: EditorProgress) { + _updates.add(progress) + } +} + +// MARK: - Mock HTTP Client for EditorAssetsLibrary Tests + +class EditorAssetsLibraryMockHTTPClient : EditorHTTPClientProtocol { + + var getResponse: ByteArray = ByteArray(0) + var getCallCount = 0 + private set + var downloadCallCount = 0 + private set + var downloadedURLs = CopyOnWriteArrayList() + private set + + private val lock = Any() + + override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse { + synchronized(lock) { + downloadCallCount++ + downloadedURLs.add(url) + } + + // Ensure parent directories exist + destination.parentFile?.mkdirs() + + // Write mock content to the destination + destination.writeText("mock content") + + return EditorHTTPClientDownloadResponse( + file = destination, + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } + + override suspend fun perform(method: String, url: String): EditorHTTPClientResponse { + synchronized(lock) { + if (method == "GET") { + getCallCount++ + } + } + + return EditorHTTPClientResponse( + data = getResponse, + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt deleted file mode 100644 index 2eaa4718..00000000 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.wordpress.gutenberg - -import org.junit.Test -import org.junit.Assert.* - -class EditorConfigurationTest { - - @Test - fun `test EditorConfiguration builder sets all properties correctly`() { - val config = EditorConfiguration.builder() - .setTitle("Test Title") - .setContent("Test Content") - .setPostId(123) - .setPostType("post") - .setThemeStyles(true) - .setPlugins(true) - .setHideTitle(false) - .setSiteURL("https://example.com") - .setSiteApiRoot("https://example.com/wp-json") - .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() - - 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 - fun `test EditorConfiguration equals and hashCode`() { - val config1 = EditorConfiguration.builder() - .setTitle("Test") - .setContent("Content") - .build() - - val config2 = EditorConfiguration.builder() - .setTitle("Test") - .setContent("Content") - .build() - - assertEquals(config1, config2) - assertEquals(config1.hashCode(), config2.hashCode()) - } - - @Test - fun `test EditorConfiguration not equals`() { - val config1 = EditorConfiguration.builder() - .setTitle("Test1") - .setContent("Content") - .build() - - val config2 = EditorConfiguration.builder() - .setTitle("Test2") - .setContent("Content") - .build() - - assertNotEquals(config1, config2) - } -} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt new file mode 100644 index 00000000..3b8368f5 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt @@ -0,0 +1,761 @@ +package org.wordpress.gutenberg + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.TimeUnit + +class EditorHTTPClientTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var mockWebServer: MockWebServer + private lateinit var baseUrl: String + + companion object { + private const val TEST_AUTH_HEADER = "Bearer test-token-12345" + } + + @Before + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + baseUrl = mockWebServer.url("/").toString() + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + private fun makeClient( + authHeader: String = TEST_AUTH_HEADER, + delegate: EditorHTTPClientDelegate? = null, + timeoutSeconds: Long = 60 + ): EditorHTTPClient { + return EditorHTTPClient( + authHeader = authHeader, + delegate = delegate, + requestTimeoutSeconds = timeoutSeconds + ) + } + + // MARK: - EditorHTTPClientResponse Tests + + @Test + fun `EditorHTTPClientResponse stringData returns UTF-8 decoded string`() { + val testString = "Hello, World!" + val response = EditorHTTPClientResponse( + data = testString.toByteArray(Charsets.UTF_8), + statusCode = 200, + headers = org.wordpress.gutenberg.model.http.EditorHTTPHeaders() + ) + + assertEquals(testString, response.stringData) + } + + @Test + fun `EditorHTTPClientResponse stringData handles unicode correctly`() { + val testString = "こんにちは世界 🌍" + val response = EditorHTTPClientResponse( + data = testString.toByteArray(Charsets.UTF_8), + statusCode = 200, + headers = org.wordpress.gutenberg.model.http.EditorHTTPHeaders() + ) + + assertEquals(testString, response.stringData) + } + + @Test + fun `EditorHTTPClientResponse equals compares data content`() { + val data = "test data".toByteArray() + val response1 = EditorHTTPClientResponse( + data = data.copyOf(), + statusCode = 200, + headers = org.wordpress.gutenberg.model.http.EditorHTTPHeaders() + ) + val response2 = EditorHTTPClientResponse( + data = data.copyOf(), + statusCode = 200, + headers = org.wordpress.gutenberg.model.http.EditorHTTPHeaders() + ) + + assertEquals(response1, response2) + } + + @Test + fun `EditorHTTPClientResponse equals returns false for different status codes`() { + val data = "test data".toByteArray() + val response1 = EditorHTTPClientResponse( + data = data, + statusCode = 200, + headers = org.wordpress.gutenberg.model.http.EditorHTTPHeaders() + ) + val response2 = EditorHTTPClientResponse( + data = data, + statusCode = 404, + headers = org.wordpress.gutenberg.model.http.EditorHTTPHeaders() + ) + + assertTrue(response1 != response2) + } + + // MARK: - WPError Tests + + @Test + fun `WPError stores code and message`() { + val error = WPError(code = "rest_forbidden", message = "Sorry, you are not allowed to do that.") + + assertEquals("rest_forbidden", error.code) + assertEquals("Sorry, you are not allowed to do that.", error.message) + } + + // MARK: - EditorHTTPClientError Tests + + @Test + fun `WPErrorResponse message includes code and message`() { + val wpError = WPError(code = "rest_forbidden", message = "Access denied") + val error = EditorHTTPClientError.WPErrorResponse(wpError) + + assertEquals("rest_forbidden: Access denied", error.message) + } + + @Test + fun `DownloadFailed message includes status code`() { + val error = EditorHTTPClientError.DownloadFailed(statusCode = 404) + + assertEquals("Download failed with status code: 404", error.message) + } + + @Test + fun `Unknown error message includes status code`() { + val error = EditorHTTPClientError.Unknown( + responseData = "error".toByteArray(), + statusCode = 500 + ) + + assertEquals("Unknown error with status code: 500", error.message) + } + + @Test + fun `Unknown error equals compares data content`() { + val data = "error data".toByteArray() + val error1 = EditorHTTPClientError.Unknown(data.copyOf(), 500) + val error2 = EditorHTTPClientError.Unknown(data.copyOf(), 500) + + assertEquals(error1, error2) + } + + // MARK: - perform() Tests + + @Test + fun `perform GET request returns response data`() = runBlocking { + val responseBody = """{"id": 1, "title": "Test Post"}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(responseBody) + ) + + val client = makeClient() + val response = client.perform("GET", "${baseUrl}wp/v2/posts/1") + + assertEquals(200, response.statusCode) + assertEquals(responseBody, response.stringData) + } + + @Test + fun `perform request includes authorization header`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val client = makeClient(authHeader = "Bearer my-secret-token") + client.perform("GET", "${baseUrl}test") + + val recordedRequest = mockWebServer.takeRequest() + assertEquals("Bearer my-secret-token", recordedRequest.getHeader("Authorization")) + } + + @Test + fun `perform request uses correct HTTP method`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val client = makeClient() + client.perform("OPTIONS", "${baseUrl}test") + + val recordedRequest = mockWebServer.takeRequest() + assertEquals("OPTIONS", recordedRequest.method) + } + + @Test + fun `perform request extracts response headers`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("{}") + .addHeader("X-Custom-Header", "custom-value") + .addHeader("Content-Type", "application/json") + ) + + val client = makeClient() + val response = client.perform("GET", "${baseUrl}test") + + assertEquals("custom-value", response.headers["X-Custom-Header"]) + assertEquals("application/json", response.headers["Content-Type"]) + } + + @Test + fun `perform request throws WPErrorResponse for WordPress error`() = runBlocking { + val wpErrorJson = """{"code": "rest_forbidden", "message": "Sorry, you are not allowed to do that."}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(403) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("rest_forbidden", e.error.code) + assertEquals("Sorry, you are not allowed to do that.", e.error.message) + } + } + + @Test + fun `perform request throws Unknown error for non-WP error response`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("Internal Server Error") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + assertEquals("Internal Server Error", e.responseData.toString(Charsets.UTF_8)) + } + } + + @Test + fun `perform request calls delegate on success`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("response data")) + + var delegateCalled = false + var capturedUrl: String? = null + var capturedMethod: String? = null + var capturedData: ByteArray? = null + + val delegate = object : EditorHTTPClientDelegate { + override fun didPerformRequest(url: String, method: String, response: Response, data: ByteArray) { + delegateCalled = true + capturedUrl = url + capturedMethod = method + capturedData = data + } + } + + val client = makeClient(delegate = delegate) + client.perform("GET", "${baseUrl}test") + + assertTrue(delegateCalled) + assertTrue(capturedUrl?.contains("test") == true) + assertEquals("GET", capturedMethod) + assertEquals("response data", capturedData?.toString(Charsets.UTF_8)) + } + + @Test + fun `perform request handles empty response body`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(204)) + + val client = makeClient() + val response = client.perform("DELETE", "${baseUrl}test") + + assertEquals(204, response.statusCode) + assertTrue(response.data.isEmpty()) + } + + // MARK: - download() Tests + + @Test + fun `download saves file to destination`() = runBlocking { + val fileContent = "file content to download" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(fileContent) + ) + + val destination = File(tempFolder.root, "downloaded.txt") + val client = makeClient() + val response = client.download("${baseUrl}file.txt", destination) + + assertEquals(200, response.statusCode) + assertTrue(destination.exists()) + assertEquals(fileContent, destination.readText()) + } + + @Test + fun `download creates parent directories`() = runBlocking { + val fileContent = "nested file content" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(fileContent) + ) + + val destination = File(tempFolder.root, "nested/path/to/downloaded.txt") + val client = makeClient() + client.download("${baseUrl}file.txt", destination) + + assertTrue(destination.exists()) + assertTrue(destination.parentFile?.exists() == true) + assertEquals(fileContent, destination.readText()) + } + + @Test + fun `download includes authorization header`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("content")) + + val destination = File(tempFolder.root, "file.txt") + val client = makeClient(authHeader = "Bearer download-token") + client.download("${baseUrl}file.txt", destination) + + val recordedRequest = mockWebServer.takeRequest() + assertEquals("Bearer download-token", recordedRequest.getHeader("Authorization")) + } + + @Test + fun `download throws DownloadFailed for 404`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(404)) + + val destination = File(tempFolder.root, "file.txt") + val client = makeClient() + + try { + client.download("${baseUrl}missing.txt", destination) + fail("Expected DownloadFailed to be thrown") + } catch (e: EditorHTTPClientError.DownloadFailed) { + assertEquals(404, e.statusCode) + } + } + + @Test + fun `download throws DownloadFailed for 500`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + + val destination = File(tempFolder.root, "file.txt") + val client = makeClient() + + try { + client.download("${baseUrl}error.txt", destination) + fail("Expected DownloadFailed to be thrown") + } catch (e: EditorHTTPClientError.DownloadFailed) { + assertEquals(500, e.statusCode) + } + } + + @Test + fun `download extracts response headers`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("content") + .addHeader("Content-Type", "text/plain") + .addHeader("Content-Length", "7") + ) + + val destination = File(tempFolder.root, "file.txt") + val client = makeClient() + val response = client.download("${baseUrl}file.txt", destination) + + assertEquals("text/plain", response.headers["Content-Type"]) + } + + @Test + fun `download returns correct file reference`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("content")) + + val destination = File(tempFolder.root, "specific-file.txt") + val client = makeClient() + val response = client.download("${baseUrl}file.txt", destination) + + assertEquals(destination.absolutePath, response.file.absolutePath) + } + + // MARK: - Constructor Tests + + @Test + fun `client can be created with custom OkHttpClient`() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val customOkHttpClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .build() + + val client = EditorHTTPClient( + authHeader = TEST_AUTH_HEADER, + okHttpClient = customOkHttpClient + ) + + val response = client.perform("GET", "${baseUrl}test") + assertEquals(200, response.statusCode) + } + + @Test + fun `client uses specified timeout`() { + // Create client with short timeout + val client = makeClient(timeoutSeconds = 1) + + // This test verifies the client is created with the specified timeout + // Actual timeout behavior would require a slow server response + assertNotNull(client) + } + + // MARK: - WordPress Error Parsing Tests + + @Test + fun `perform parses WordPress error with data field`() = runBlocking { + val wpErrorJson = """{"code": "rest_invalid_param", "message": "Invalid parameter", "data": {"status": 400}}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("rest_invalid_param", e.error.code) + assertEquals("Invalid parameter", e.error.message) + } + } + + @Test + fun `perform returns Unknown error when error JSON is malformed`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("{not valid json") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + } + } + + @Test + fun `perform returns Unknown error when error JSON is missing code`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("""{"message": "Something went wrong"}""") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + } + } + + @Test + fun `perform returns Unknown error when error JSON is missing message`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("""{"code": "some_error"}""") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + } + } + + @Test + fun `perform parses error with nested data params`() = runBlocking { + val wpErrorJson = """{"code": "rest_invalid_param", "message": "Invalid parameter(s): title", "data": {"status": 400, "params": {"title": "Title is required."}}}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("rest_invalid_param", e.error.code) + assertEquals("Invalid parameter(s): title", e.error.message) + } + } + + @Test + fun `perform parses error with additional_errors array`() = runBlocking { + val wpErrorJson = """{"code": "rest_invalid_param", "message": "Invalid parameter(s): title, content", "data": {"status": 400}, "additional_errors": [{"code": "rest_invalid_field", "message": "Content is required."}]}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("rest_invalid_param", e.error.code) + assertEquals("Invalid parameter(s): title, content", e.error.message) + } + } + + @Test + fun `perform parses error with unicode characters in message`() = runBlocking { + val wpErrorJson = """{"code": "custom_error", "message": "エラーが発生しました 🚫", "data": {"status": 500}}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("custom_error", e.error.code) + assertEquals("エラーが発生しました 🚫", e.error.message) + } + } + + @Test + fun `perform parses error without data field`() = runBlocking { + val wpErrorJson = """{"code": "simple_error", "message": "A simple error occurred."}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("simple_error", e.error.code) + assertEquals("A simple error occurred.", e.error.message) + } + } + + @Test + fun `perform parses WPError when code is empty string`() = runBlocking { + val wpErrorJson = """{"code": "", "message": "Error with empty code"}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("", e.error.code) + assertEquals("Error with empty code", e.error.message) + } + } + + @Test + fun `perform parses WPError when message is empty string`() = runBlocking { + val wpErrorJson = """{"code": "empty_message", "message": ""}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("empty_message", e.error.code) + assertEquals("", e.error.message) + } + } + + @Test + fun `perform returns Unknown error when response is empty JSON object`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("{}") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + } + } + + @Test + fun `perform returns Unknown error when response is JSON array`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("""[{"code": "error", "message": "test"}]""") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + } + } + + @Test + fun `perform returns Unknown error when response is plain text`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("Internal Server Error") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + assertEquals("Internal Server Error", e.responseData.toString(Charsets.UTF_8)) + } + } + + @Test + fun `perform returns Unknown error when response is HTML`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("

500 Internal Server Error

") + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected Unknown error to be thrown") + } catch (e: EditorHTTPClientError.Unknown) { + assertEquals(500, e.statusCode) + } + } + + @Test + fun `perform parses error with null data field`() = runBlocking { + val wpErrorJson = """{"code": "null_data_error", "message": "Error with null data", "data": null}""" + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody(wpErrorJson) + ) + + val client = makeClient() + + try { + client.perform("GET", "${baseUrl}test") + fail("Expected WPErrorResponse to be thrown") + } catch (e: EditorHTTPClientError.WPErrorResponse) { + assertEquals("null_data_error", e.error.code) + assertEquals("Error with null data", e.error.message) + } + } + + @Test + fun `WPErrorResponse message format is correct`() { + val wpError = WPError(code = "test_code", message = "Test message") + val error = EditorHTTPClientError.WPErrorResponse(wpError) + + assertEquals("test_code: Test message", error.message) + } + + // MARK: - Edge Cases + + @Test + fun `perform handles 201 Created as success`() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(201) + .setBody("""{"id": 123}""") + ) + + val client = makeClient() + val response = client.perform("POST", "${baseUrl}test") + + assertEquals(201, response.statusCode) + } + + @Test + fun `download handles binary content`() = runBlocking { + val binaryContent = byteArrayOf(0x00, 0x01, 0x02, 0xFF.toByte(), 0xFE.toByte()) + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(okio.Buffer().write(binaryContent)) + ) + + val destination = File(tempFolder.root, "binary.bin") + val client = makeClient() + client.download("${baseUrl}file.bin", destination) + + assertTrue(destination.readBytes().contentEquals(binaryContent)) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorURLCacheTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorURLCacheTest.kt new file mode 100644 index 00000000..d9b3899f --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorURLCacheTest.kt @@ -0,0 +1,595 @@ +package org.wordpress.gutenberg + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.wordpress.gutenberg.model.EditorCachePolicy +import org.wordpress.gutenberg.model.TestResources +import org.wordpress.gutenberg.model.http.EditorHTTPHeaders +import org.wordpress.gutenberg.model.http.EditorHttpMethod +import org.wordpress.gutenberg.model.http.EditorURLResponse +import org.wordpress.gutenberg.stores.EditorURLCache +import java.io.File +import java.util.Date +import java.util.UUID + +class EditorURLCacheTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var cacheRoot: File + private lateinit var cache: EditorURLCache + + private val testURL = "https://example.com/api/posts" + + @Before + fun setUp() { + cacheRoot = tempFolder.newFolder("cache") + cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + } + + private fun makeResponse( + data: String = UUID.randomUUID().toString(), + headers: EditorHTTPHeaders = EditorHTTPHeaders() + ): EditorURLResponse { + return EditorURLResponse(data = data, responseHeaders = headers) + } + + // MARK: - store(_:for:httpMethod:) and getResponse(for:httpMethod:) + + @Test + fun `store and retrieve response by URL`() { + val response = makeResponse() + cache.store(response, testURL, EditorHttpMethod.GET) + val fetched = cache.getResponse(testURL, EditorHttpMethod.GET) + assertEquals(response, fetched) + } + + @Test + fun `storing response overwrites the previous value`() { + cache.store(makeResponse(), testURL, EditorHttpMethod.GET) + val newResponse = makeResponse(data = "new value") + cache.store(newResponse, testURL, EditorHttpMethod.GET) + assertEquals(newResponse, cache.getResponse(testURL, EditorHttpMethod.GET)) + } + + @Test + fun `storing response with empty data works`() { + val response = makeResponse(data = "") + cache.store(response, testURL, EditorHttpMethod.GET) + val fetched = cache.getResponse(testURL, EditorHttpMethod.GET) + assertEquals("", fetched?.data) + } + + @Test + fun `response for non-existent URL returns nil`() { + val missingURL = "https://example.com/missing" + assertNull(cache.getResponse(missingURL, EditorHttpMethod.GET)) + } + + @Test + fun `different URLs are independent`() { + val url1 = "https://example.com/posts/1" + val url2 = "https://example.com/posts/2" + val response1 = makeResponse(data = "post 1") + val response2 = makeResponse(data = "post 2") + cache.store(response1, url1, EditorHttpMethod.GET) + cache.store(response2, url2, EditorHttpMethod.GET) + + val fetchedResponse1 = cache.getResponse(url1, EditorHttpMethod.GET) + val fetchedResponse2 = cache.getResponse(url2, EditorHttpMethod.GET) + + assertEquals(response1, fetchedResponse1) + assertEquals(response2, fetchedResponse2) + } + + @Test + fun `response includes headers`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "application/json", "X-Custom" to "value")) + val response = makeResponse(headers = headers) + cache.store(response, testURL, EditorHttpMethod.GET) + val retrieved = cache.getResponse(testURL, EditorHttpMethod.GET) + assertEquals("application/json", retrieved?.responseHeaders?.get("Content-Type")) + assertEquals("value", retrieved?.responseHeaders?.get("X-Custom")) + } + + // MARK: - store(file:headers:url:httpMethod:) + + @Test + fun `store file copies file data`() { + val fileContent = TestResources.loadResource("post-test-case-1.json") + val file = tempFolder.newFile("test.json") + file.writeText(fileContent) + + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + cache.store(file, headers, testURL, EditorHttpMethod.GET) + assertEquals(fileContent, cache.getResponse(testURL, EditorHttpMethod.GET)?.data) + } + + @Test + fun `store file preserves headers`() { + val file = tempFolder.newFile("test.json") + file.writeText("{}") + + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "application/json", "X-Version" to "1.0")) + cache.store(file, headers, testURL, EditorHttpMethod.GET) + val retrieved = cache.getResponse(testURL, EditorHttpMethod.GET) + assertEquals("application/json", retrieved?.responseHeaders?.get("Content-Type")) + assertEquals("1.0", retrieved?.responseHeaders?.get("X-Version")) + } + + @Test + fun `store file twice overwrites prior value`() { + val firstContent = TestResources.loadResource("post-test-case-1.json") + val secondContent = TestResources.loadResource("post-test-case-163.json") + + val firstFile = tempFolder.newFile("first.json") + firstFile.writeText(firstContent) + + val secondFile = tempFolder.newFile("second.json") + secondFile.writeText(secondContent) + + val headers = EditorHTTPHeaders() + cache.store(firstFile, headers, testURL, EditorHttpMethod.GET) + cache.store(secondFile, headers, testURL, EditorHttpMethod.GET) + assertEquals(secondContent, cache.getResponse(testURL, EditorHttpMethod.GET)?.data) + } + + @Test + fun `store file leaves original file`() { + val file = tempFolder.newFile("test.json") + file.writeText("{}") + + cache.store(file, EditorHTTPHeaders(), testURL, EditorHttpMethod.GET) + assertTrue(file.exists()) + } + + @Test(expected = Exception::class) + fun `store invalid file throws`() { + val invalidPath = File("/nonexistent/path/file.txt") + cache.store(invalidPath, EditorHTTPHeaders(), testURL, EditorHttpMethod.GET) + } + + // MARK: - hasResponse(url:httpMethod:) + + @Test + fun `hasResponse returns true for existing entry stored via response`() { + cache.store(makeResponse(), testURL, EditorHttpMethod.GET) + assertTrue(cache.hasResponse(testURL, EditorHttpMethod.GET)) + } + + @Test + fun `hasResponse returns true for existing entry stored via file`() { + val file = tempFolder.newFile("test.json") + file.writeText("{}") + + cache.store(file, EditorHTTPHeaders(), testURL, EditorHttpMethod.GET) + assertTrue(cache.hasResponse(testURL, EditorHttpMethod.GET)) + } + + @Test + fun `hasResponse returns false for missing entry`() { + val missingURL = "https://example.com/missing" + assertFalse(cache.hasResponse(missingURL, EditorHttpMethod.GET)) + } + + // MARK: - purge() + + @Test + fun `purge removes all entries`() { + cache.store(makeResponse(), testURL, EditorHttpMethod.GET) + val otherURL = "https://example.com/other" + cache.store(makeResponse(), otherURL, EditorHttpMethod.GET) + cache.purge() + + assertNull(cache.getResponse(testURL, EditorHttpMethod.GET)) + assertNull(cache.getResponse(otherURL, EditorHttpMethod.GET)) + } + + @Test + fun `store succeeds after purge`() { + cache.store(makeResponse(), testURL, EditorHttpMethod.GET) + cache.purge() + val newResponse = makeResponse(data = "after purge") + cache.store(newResponse, testURL, EditorHttpMethod.GET) + assertEquals(newResponse, cache.getResponse(testURL, EditorHttpMethod.GET)) + } + + // MARK: - clean() + + @Test + fun `clean with Always policy removes nothing`() { + val alwaysCache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + alwaysCache.store(makeResponse(), testURL, EditorHttpMethod.GET) + alwaysCache.store(makeResponse(), "https://example.com/other", EditorHttpMethod.GET) + + val removedCount = alwaysCache.clean() + + assertEquals(0, removedCount) + assertTrue(alwaysCache.hasResponse(testURL, EditorHttpMethod.GET)) + } + + @Test + fun `clean with MaxAge policy removes expired entries`() { + val maxAgeCache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(intervalMillis = 3600_000)) // 1 hour + val oldDate = Date(0) // Very old date + val recentDate = Date() // Now + + // Store an old entry and a recent entry + maxAgeCache.store(makeResponse(data = "old"), testURL, EditorHttpMethod.GET, oldDate) + maxAgeCache.store(makeResponse(data = "recent"), "https://example.com/recent", EditorHttpMethod.GET, recentDate) + + val removedCount = maxAgeCache.clean(Date()) + + assertEquals(1, removedCount) + assertNull(maxAgeCache.getResponse(testURL, EditorHttpMethod.GET)) + assertTrue(maxAgeCache.hasResponse("https://example.com/recent", EditorHttpMethod.GET)) + } + + @Test + fun `clean with Ignore policy removes all entries`() { + val ignoreCache = EditorURLCache(cacheRoot, EditorCachePolicy.Ignore) + ignoreCache.store(makeResponse(), testURL, EditorHttpMethod.GET) + ignoreCache.store(makeResponse(), "https://example.com/other", EditorHttpMethod.GET) + + val removedCount = ignoreCache.clean() + + assertEquals(2, removedCount) + assertNull(ignoreCache.getResponse(testURL, EditorHttpMethod.GET)) + } + + @Test + fun `clean removes corrupted entries`() { + cache.store(makeResponse(), testURL, EditorHttpMethod.GET) + + // Write a corrupted file directly to the cache directory + val corruptedFile = File(cacheRoot, "corrupted-entry") + corruptedFile.writeText("not valid json") + + val removedCount = cache.clean() + + // The corrupted file should be removed, but valid entries remain + assertEquals(1, removedCount) + assertFalse(corruptedFile.exists()) + assertTrue(cache.hasResponse(testURL, EditorHttpMethod.GET)) + } + + @Test + fun `clean returns zero when cache is empty`() { + val removedCount = cache.clean() + assertEquals(0, removedCount) + } + + // MARK: - URLs with query parameters + + @Test + fun `URLs with different query parameters are independent`() { + val url1 = "https://example.com/posts?page=1" + val url2 = "https://example.com/posts?page=2" + val response1 = makeResponse(data = "page 1") + val response2 = makeResponse(data = "page 2") + cache.store(response1, url1, EditorHttpMethod.GET) + cache.store(response2, url2, EditorHttpMethod.GET) + assertEquals(response1, cache.getResponse(url1, EditorHttpMethod.GET)) + assertEquals(response2, cache.getResponse(url2, EditorHttpMethod.GET)) + } + + @Test + fun `URL with and without query parameters are independent`() { + val urlWithQuery = "https://example.com/posts?context=edit" + val urlWithoutQuery = "https://example.com/posts" + val response1 = makeResponse(data = "with query") + val response2 = makeResponse(data = "without query") + cache.store(response1, urlWithQuery, EditorHttpMethod.GET) + cache.store(response2, urlWithoutQuery, EditorHttpMethod.GET) + assertEquals(response1, cache.getResponse(urlWithQuery, EditorHttpMethod.GET)) + assertEquals(response2, cache.getResponse(urlWithoutQuery, EditorHttpMethod.GET)) + } +} + +// MARK: - Cache Policy Tests: Ignore + +class EditorURLCacheIgnorePolicyTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var cacheRoot: File + private lateinit var cache: EditorURLCache + + private val testURL = "https://example.com/api/posts" + + /** A fixed reference date for deterministic testing. */ + private val referenceDate = Date(0) + + @Before + fun setUp() { + cacheRoot = tempFolder.newFolder("cache") + cache = EditorURLCache(cacheRoot, EditorCachePolicy.Ignore) + } + + private fun makeResponse( + data: String = UUID.randomUUID().toString(), + headers: EditorHTTPHeaders = EditorHTTPHeaders() + ): EditorURLResponse { + return EditorURLResponse(data = data, responseHeaders = headers) + } + + @Test + fun `ignore policy - response returns nil even after storing`() { + val response = makeResponse() + cache.store(response, testURL, EditorHttpMethod.GET, referenceDate) + + // With ignore policy, cached responses should never be returned + assertNull(cache.getResponse(testURL, EditorHttpMethod.GET, referenceDate)) + } + + @Test + fun `ignore policy - hasResponse returns false even after storing`() { + cache.store(makeResponse(), testURL, EditorHttpMethod.GET, referenceDate) + + // With ignore policy, hasResponse should return false + assertFalse(cache.hasResponse(testURL, EditorHttpMethod.GET, referenceDate)) + } + + @Test + fun `ignore policy - multiple stores still return nil`() { + val url1 = "https://example.com/posts/1" + val url2 = "https://example.com/posts/2" + + cache.store(makeResponse(data = "post 1"), url1, EditorHttpMethod.GET, referenceDate) + cache.store(makeResponse(data = "post 2"), url2, EditorHttpMethod.GET, referenceDate) + + assertNull(cache.getResponse(url1, EditorHttpMethod.GET, referenceDate)) + assertNull(cache.getResponse(url2, EditorHttpMethod.GET, referenceDate)) + } + + @Test + fun `ignore policy - file store also returns nil`() { + val file = tempFolder.newFile("test.json") + file.writeText("{}") + + cache.store(file, EditorHTTPHeaders(), testURL, EditorHttpMethod.GET, referenceDate) + + assertNull(cache.getResponse(testURL, EditorHttpMethod.GET, referenceDate)) + } +} + +// MARK: - Cache Policy Tests: MaxAge + +class EditorURLCacheMaxAgePolicyTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var cacheRoot: File + + private val testURL = "https://example.com/api/posts" + + /** A fixed reference date for deterministic testing. */ + private val referenceDate = Date(0) + + @Before + fun setUp() { + cacheRoot = tempFolder.newFolder("cache") + } + + private fun makeResponse( + data: String = UUID.randomUUID().toString(), + headers: EditorHTTPHeaders = EditorHTTPHeaders() + ): EditorURLResponse { + return EditorURLResponse(data = data, responseHeaders = headers) + } + + @Test + fun `maxAge policy - fresh response is returned`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + val response = makeResponse() + + // Store at reference date + cache.store(response, testURL, EditorHttpMethod.GET, referenceDate) + + // Retrieve at the same time - should be fresh + assertEquals(response, cache.getResponse(testURL, EditorHttpMethod.GET, referenceDate)) + } + + @Test + fun `maxAge policy - response within interval is returned`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + val response = makeResponse() + + // Store at reference date + cache.store(response, testURL, EditorHttpMethod.GET, referenceDate) + + // Retrieve 30 seconds later - should still be fresh + val thirtySecondsLater = Date(referenceDate.time + 30_000) + assertEquals(response, cache.getResponse(testURL, EditorHttpMethod.GET, thirtySecondsLater)) + } + + @Test + fun `maxAge policy - hasResponse returns true for fresh response`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + + cache.store(makeResponse(), testURL, EditorHttpMethod.GET, referenceDate) + + assertTrue(cache.hasResponse(testURL, EditorHttpMethod.GET, referenceDate)) + } + + @Test + fun `maxAge policy - expired response returns nil`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + val response = makeResponse() + + // Store at reference date + cache.store(response, testURL, EditorHttpMethod.GET, referenceDate) + + // Retrieve 2 minutes later - should be expired + val twoMinutesLater = Date(referenceDate.time + 120_000) + assertNull(cache.getResponse(testURL, EditorHttpMethod.GET, twoMinutesLater)) + } + + @Test + fun `maxAge policy - hasResponse returns false for expired response`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + + cache.store(makeResponse(), testURL, EditorHttpMethod.GET, referenceDate) + + val twoMinutesLater = Date(referenceDate.time + 120_000) + assertFalse(cache.hasResponse(testURL, EditorHttpMethod.GET, twoMinutesLater)) + } + + @Test + fun `maxAge policy - response at exact boundary is expired`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + + cache.store(makeResponse(), testURL, EditorHttpMethod.GET, referenceDate) + + // At exactly 60 seconds, the response expires (using > comparison) + val exactlySixtySecondsLater = Date(referenceDate.time + 60_000) + assertNull(cache.getResponse(testURL, EditorHttpMethod.GET, exactlySixtySecondsLater)) + } + + @Test + fun `maxAge policy - response just before boundary is fresh`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + val response = makeResponse() + + cache.store(response, testURL, EditorHttpMethod.GET, referenceDate) + + // At 59 seconds, the response is still fresh + val fiftyNineSecondsLater = Date(referenceDate.time + 59_000) + assertEquals(response, cache.getResponse(testURL, EditorHttpMethod.GET, fiftyNineSecondsLater)) + } + + @Test + fun `maxAge policy - zero interval means immediate expiration`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(0)) + + cache.store(makeResponse(), testURL, EditorHttpMethod.GET, referenceDate) + + // Even at the same moment, the response should be expired + assertNull(cache.getResponse(testURL, EditorHttpMethod.GET, referenceDate)) + } + + @Test + fun `maxAge policy - re-storing refreshes the expiration`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + + // Store initial response at reference date + cache.store(makeResponse(data = "first"), testURL, EditorHttpMethod.GET, referenceDate) + + // 50 seconds later, re-store with new data + val fiftySecondsLater = Date(referenceDate.time + 50_000) + val newResponse = makeResponse(data = "second") + cache.store(newResponse, testURL, EditorHttpMethod.GET, fiftySecondsLater) + + // 80 seconds from original store - original would have expired, but re-store refreshed it + val eightySecondsLater = Date(referenceDate.time + 80_000) + assertEquals(newResponse, cache.getResponse(testURL, EditorHttpMethod.GET, eightySecondsLater)) + } + + @Test + fun `maxAge policy - different URLs expire independently`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + + val url1 = "https://example.com/posts/1" + val url2 = "https://example.com/posts/2" + + // Store first URL at reference date + cache.store(makeResponse(data = "post 1"), url1, EditorHttpMethod.GET, referenceDate) + + // Store second URL 30 seconds later + val thirtySecondsLater = Date(referenceDate.time + 30_000) + val response2 = makeResponse(data = "post 2") + cache.store(response2, url2, EditorHttpMethod.GET, thirtySecondsLater) + + // At 70 seconds from start: url1 should be expired, url2 should still be fresh + val seventySecondsLater = Date(referenceDate.time + 70_000) + + // First URL expired (stored at 0, maxAge 60, current time 70) + assertNull(cache.getResponse(url1, EditorHttpMethod.GET, seventySecondsLater)) + + // Second URL still fresh (stored at 30, maxAge 60, expires at 90, current time 70) + assertEquals(response2, cache.getResponse(url2, EditorHttpMethod.GET, seventySecondsLater)) + } + + @Test + fun `maxAge policy - file store respects cache policy`() { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.MaxAge(60_000)) + val fileContent = TestResources.loadResource("post-test-case-1.json") + val file = tempFolder.newFile("test.json") + file.writeText(fileContent) + + cache.store(file, EditorHTTPHeaders(), testURL, EditorHttpMethod.GET, referenceDate) + + // Fresh: should return data + assertEquals(fileContent, cache.getResponse(testURL, EditorHttpMethod.GET, referenceDate)?.data) + + // Expired: should return nil + val twoMinutesLater = Date(referenceDate.time + 120_000) + assertNull(cache.getResponse(testURL, EditorHttpMethod.GET, twoMinutesLater)) + } +} + +// MARK: - Cache Policy Tests: Always + +class EditorURLCacheAlwaysPolicyTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var cacheRoot: File + private lateinit var cache: EditorURLCache + + private val testURL = "https://example.com/api/posts" + + /** A fixed reference date for deterministic testing. */ + private val referenceDate = Date(0) + + @Before + fun setUp() { + cacheRoot = tempFolder.newFolder("cache") + cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + } + + private fun makeResponse( + data: String = UUID.randomUUID().toString(), + headers: EditorHTTPHeaders = EditorHTTPHeaders() + ): EditorURLResponse { + return EditorURLResponse(data = data, responseHeaders = headers) + } + + @Test + fun `always policy - response is returned at same time`() { + val response = makeResponse() + cache.store(response, testURL, EditorHttpMethod.GET, referenceDate) + + assertEquals(response, cache.getResponse(testURL, EditorHttpMethod.GET, referenceDate)) + } + + @Test + fun `always policy - response is returned regardless of time elapsed`() { + val response = makeResponse() + cache.store(response, testURL, EditorHttpMethod.GET, referenceDate) + + // Even years later, response should still be available + val tenYearsLater = Date(referenceDate.time + (10L * 365 * 24 * 60 * 60 * 1000)) + assertEquals(response, cache.getResponse(testURL, EditorHttpMethod.GET, tenYearsLater)) + } + + @Test + fun `always policy - hasResponse returns true regardless of time`() { + cache.store(makeResponse(), testURL, EditorHttpMethod.GET, referenceDate) + + assertTrue(cache.hasResponse(testURL, EditorHttpMethod.GET, referenceDate)) + + val tenYearsLater = Date(referenceDate.time + (10L * 365 * 24 * 60 * 60 * 1000)) + assertTrue(cache.hasResponse(testURL, EditorHttpMethod.GET, tenYearsLater)) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/ExampleUnitTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/ExampleUnitTest.kt deleted file mode 100644 index 67c587d1..00000000 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.wordpress.gutenberg - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index ede20c98..72cd9b16 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -6,8 +6,10 @@ import android.os.Looper import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebView +import kotlinx.coroutines.test.TestScope import org.junit.Before import org.junit.Test +import org.junit.Rule import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.`when` @@ -20,6 +22,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -35,11 +39,18 @@ class GutenbergViewTest { private lateinit var gutenbergView: GutenbergView + val testScope = TestScope() // Creates a StandardTestDispatcher + @Before fun setup() { MockitoAnnotations.openMocks(this) - gutenbergView = GutenbergView(RuntimeEnvironment.getApplication()) - gutenbergView.initializeWebView() + + gutenbergView = GutenbergView( + EditorConfiguration.bundled(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) } @Test diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt new file mode 100644 index 00000000..ab56c8bd --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -0,0 +1,414 @@ +package org.wordpress.gutenberg + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.wordpress.gutenberg.model.EditorCachePolicy +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorSettings +import org.wordpress.gutenberg.model.http.EditorHTTPHeaders +import org.wordpress.gutenberg.stores.EditorURLCache +import java.io.File + +class RESTAPIRepositoryTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var cacheRoot: File + + companion object { + private const val TEST_SITE_URL = "https://example.com" + private const val TEST_API_ROOT = "https://example.com/wp-json" + } + + @Before + fun setUp() { + cacheRoot = tempFolder.newFolder("cache") + } + + // MARK: - Test Fixtures + + private fun makeConfiguration( + shouldUsePlugins: Boolean = true, + shouldUseThemeStyles: Boolean = true + ): EditorConfiguration { + return EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "post") + .setPlugins(shouldUsePlugins) + .setThemeStyles(shouldUseThemeStyles) + .setAuthHeader("Bearer test-token") + .build() + } + + private fun makeRepository( + configuration: EditorConfiguration = makeConfiguration(), + httpClient: EditorHTTPClientProtocol = MockHTTPClient() + ): RESTAPIRepository { + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + return RESTAPIRepository( + configuration = configuration, + httpClient = httpClient, + cache = cache + ) + } + + // MARK: - fetchPost Tests + + @Test + fun `fetchPost returns response for valid post ID`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """{"id":123,"title":{"raw":"Test Post"}}""" + + val repository = makeRepository(httpClient = mockClient) + + val response = repository.fetchPost(id = 123) + + assertTrue(response.data.isNotEmpty()) + assertEquals(1, mockClient.getCallCount) + } + + // MARK: - fetchEditorSettings Tests + + @Test + fun `fetchEditorSettings returns empty when plugins and theme styles disabled`() = runBlocking { + val configuration = makeConfiguration(shouldUsePlugins = false, shouldUseThemeStyles = false) + val mockClient = MockHTTPClient() + val repository = makeRepository(configuration = configuration, httpClient = mockClient) + + val settings = repository.fetchEditorSettings() + + assertEquals(EditorSettings.undefined, settings) + assertEquals(0, mockClient.getCallCount) + } + + @Test + fun `fetchEditorSettings parses response correctly`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """{"styles":[{"css":".test{color:red}","isGlobalStyles":false}]}""" + + val repository = makeRepository(httpClient = mockClient) + + val settings = repository.fetchEditorSettings() + + assertTrue(settings.themeStyles.contains(".test{color:red}")) + } + + @Test + fun `fetchEditorSettings caches response`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """{"styles":[]}""" + + val configuration = makeConfiguration() + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository( + configuration = configuration, + httpClient = mockClient, + cache = cache + ) + + repository.fetchEditorSettings() + + // Should be able to read from cache + val cached = repository.readEditorSettings() + assertNotNull(cached) + } + + // MARK: - readEditorSettings Tests + + @Test + fun `readEditorSettings returns null when not cached`() { + val repository = makeRepository() + + val settings = repository.readEditorSettings() + + assertNull(settings) + } + + @Test + fun `readEditorSettings returns same values as fetchEditorSettings`() = runBlocking { + val mockClient = MockHTTPClient() + val rawJSON = """{"styles":[{"css":".theme-style{color:blue}","isGlobalStyles":true},{"css":".another{margin:0}","isGlobalStyles":false}]}""" + mockClient.getResponse = rawJSON + + val configuration = makeConfiguration() + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository( + configuration = configuration, + httpClient = mockClient, + cache = cache + ) + + val fetched = repository.fetchEditorSettings() + val cached = repository.readEditorSettings() + + assertNotNull(cached) + assertEquals(cached?.stringValue, fetched.stringValue) + assertEquals(cached?.themeStyles, fetched.themeStyles) + assertTrue(cached?.themeStyles?.contains(".theme-style{color:blue}") == true) + assertTrue(cached?.themeStyles?.contains(".another{margin:0}") == true) + } + + // MARK: - fetchPostType Tests + + @Test + fun `fetchPostType returns response`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """{"slug":"post","name":"Posts"}""" + + val repository = makeRepository(httpClient = mockClient) + + val response = repository.fetchPostType("post") + + assertTrue(response.data.isNotEmpty()) + } + + @Test + fun `fetchPostType caches response`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """{"slug":"post"}""" + + val configuration = makeConfiguration() + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository( + configuration = configuration, + httpClient = mockClient, + cache = cache + ) + + repository.fetchPostType("post") + + assertNotNull(repository.readPostType("post")) + } + + // MARK: - fetchActiveTheme Tests + + @Test + fun `fetchActiveTheme returns response`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """[{"stylesheet":"twentytwentyfour"}]""" + + val repository = makeRepository(httpClient = mockClient) + + val response = repository.fetchActiveTheme() + + assertTrue(response.data.isNotEmpty()) + } + + @Test + fun `fetchActiveTheme caches response`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """[{"stylesheet":"theme"}]""" + + val configuration = makeConfiguration() + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository( + configuration = configuration, + httpClient = mockClient, + cache = cache + ) + + repository.fetchActiveTheme() + + assertNotNull(repository.readActiveTheme()) + } + + // MARK: - fetchSettingsOptions Tests + + @Test + fun `fetchSettingsOptions returns response`() = runBlocking { + val mockClient = MockHTTPClient() + + val repository = makeRepository(httpClient = mockClient) + + val response = repository.fetchSettingsOptions() + + // OPTIONS returns default empty response from mock + assertNotNull(response) + } + + // MARK: - fetchPostTypes Tests + + @Test + fun `fetchPostTypes returns response`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """{"post":{"slug":"post"},"page":{"slug":"page"}}""" + + val repository = makeRepository(httpClient = mockClient) + + val response = repository.fetchPostTypes() + + assertTrue(response.data.isNotEmpty()) + } + + @Test + fun `fetchPostTypes caches response`() = runBlocking { + val mockClient = MockHTTPClient() + mockClient.getResponse = """{"post":{}}""" + + val configuration = makeConfiguration() + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository( + configuration = configuration, + httpClient = mockClient, + cache = cache + ) + + repository.fetchPostTypes() + + assertNotNull(repository.readPostTypes()) + } + + // MARK: - URL Building Tests + + @Test + fun `URLs are normalized when API root has no trailing slash`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://example.com/wp-json", // No trailing slash + "post" + ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + repository.fetchPostType("post") + repository.fetchActiveTheme() + repository.fetchPostTypes() + + val expectedURLs = setOf( + "https://example.com/wp-json/wp/v2/posts/1?context=edit", + "https://example.com/wp-json/wp/v2/types/post?context=edit", + "https://example.com/wp-json/wp/v2/themes?context=edit&status=active", + "https://example.com/wp-json/wp/v2/types?context=view" + ) + + assertEquals(expectedURLs, capturedURLs.toSet()) + } + + @Test + fun `URLs are normalized when API root has trailing slash`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://example.com/wp-json/", // With trailing slash + "post" + ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + repository.fetchPostType("post") + repository.fetchActiveTheme() + repository.fetchPostTypes() + + val expectedURLs = setOf( + "https://example.com/wp-json/wp/v2/posts/1?context=edit", + "https://example.com/wp-json/wp/v2/types/post?context=edit", + "https://example.com/wp-json/wp/v2/themes?context=edit&status=active", + "https://example.com/wp-json/wp/v2/types?context=view" + ) + + assertEquals(expectedURLs, capturedURLs.toSet()) + } + + private fun createCapturingClient(onRequest: (String) -> Unit): EditorHTTPClientProtocol { + return object : EditorHTTPClientProtocol { + override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse { + throw NotImplementedError() + } + + override suspend fun perform(method: String, url: String): EditorHTTPClientResponse { + onRequest(url) + return EditorHTTPClientResponse( + data = "{}".toByteArray(), + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } + } + } + + @Test + fun `post URL includes context=edit query parameter`() = runBlocking { + var capturedURL: String? = null + val capturingClient = object : EditorHTTPClientProtocol { + override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse { + throw NotImplementedError() + } + + override suspend fun perform(method: String, url: String): EditorHTTPClientResponse { + capturedURL = url + return EditorHTTPClientResponse( + data = "{}".toByteArray(), + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } + } + + val configuration = makeConfiguration() + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository( + configuration = configuration, + httpClient = capturingClient, + cache = cache + ) + + repository.fetchPost(id = 42) + + assertTrue(capturedURL?.contains("context=edit") == true) + assertTrue(capturedURL?.contains("/posts/42") == true) + } +} + +// MARK: - Mock HTTP Client + +/** + * A mock HTTP client for testing that returns configurable responses. + */ +class MockHTTPClient : EditorHTTPClientProtocol { + var getResponse: String = "{}" + var optionsResponse: String = "{}" + var getCallCount: Int = 0 + private set + + override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse { + destination.parentFile?.mkdirs() + destination.writeText(getResponse) + return EditorHTTPClientDownloadResponse( + file = destination, + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } + + override suspend fun perform(method: String, url: String): EditorHTTPClientResponse { + if (method == "GET") { + getCallCount++ + } + + val responseData = when (method) { + "OPTIONS" -> optionsResponse + else -> getResponse + } + + return EditorHTTPClientResponse( + data = responseData.toByteArray(), + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt new file mode 100644 index 00000000..f5e85d59 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt @@ -0,0 +1,601 @@ +package org.wordpress.gutenberg.model + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.util.Date +import java.util.UUID + +class EditorAssetBundleTest { + + private val json = Json { ignoreUnknownKeys = true } + + // MARK: - Initialization Tests + + @Test + fun `Default initialization creates bundle with empty manifest`() { + val bundle = makeBundle() + + assertTrue(bundle.manifest.scripts.isEmpty()) + assertTrue(bundle.manifest.styles.isEmpty()) + assertTrue(bundle.manifest.allowedBlockTypes.isEmpty()) + } + + @Test + fun `Default initialization sets downloadDate to current time`() { + val beforeCreation = Date() + val bundle = makeBundle() + val afterCreation = Date() + + assertTrue(bundle.downloadDate >= beforeCreation) + assertTrue(bundle.downloadDate <= afterCreation) + } + + @Test + fun `Initialization with manifest preserves manifest data`() { + val manifest = createManifest( + scripts = """""", + styles = """""", + blockTypes = listOf("core/paragraph", "core/heading") + ) + + val bundle = makeBundle(manifest = manifest) + + assertEquals(1, bundle.manifest.scripts.size) + assertEquals(1, bundle.manifest.styles.size) + assertEquals(listOf("core/paragraph", "core/heading"), bundle.manifest.allowedBlockTypes) + } + + @Test + fun `Initialization with custom downloadDate preserves date`() { + val customDate = Date(1_000_000_000L) + val bundle = makeBundle(downloadDate = customDate) + + assertEquals(customDate, bundle.downloadDate) + } + + // MARK: - ID Tests + + @Test + fun `Bundle ID equals manifest checksum`() { + val manifest = createManifest(blockTypes = listOf("core/paragraph")) + val bundle = makeBundle(manifest = manifest) + + assertEquals(manifest.checksum, bundle.id) + } + + @Test + fun `Empty bundle has empty ID`() { + val bundle = makeBundle() + assertEquals("empty", bundle.id) + } + + @Test + fun `Different manifests produce different bundle IDs`() { + val manifest1 = createManifest(blockTypes = listOf("core/paragraph")) + val manifest2 = createManifest(blockTypes = listOf("core/heading")) + + val bundle1 = makeBundle(manifest = manifest1) + val bundle2 = makeBundle(manifest = manifest2) + + assertNotEquals(bundle1.id, bundle2.id) + } + + @Test + fun `Same manifest data produces same bundle ID`() { + val jsonString = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": ["core/paragraph"] + } + """.trimIndent() + + val manifest1 = createManifestFromJson(jsonString) + val manifest2 = createManifestFromJson(jsonString) + + val bundle1 = makeBundle(manifest = manifest1) + val bundle2 = makeBundle(manifest = manifest2) + + assertEquals(bundle1.id, bundle2.id) + } + + // MARK: - assetCount Tests + + @Test + fun `assetCount returns zero for empty bundle`() { + val bundle = makeBundle() + assertEquals(0, bundle.assetCount) + } + + @Test + fun `assetCount reflects manifest asset URLs`() { + val manifest = createManifest( + scripts = """""", + styles = """""" + ) + val bundle = makeBundle(manifest = manifest) + + assertEquals(3, bundle.assetCount) + } + + // MARK: - Codable Tests + + @Test + fun `Bundle can be encoded and decoded`() { + val manifest = createManifest( + scripts = """""", + blockTypes = listOf("core/paragraph", "core/image") + ) + val originalBundle = makeBundle(manifest = manifest) + + val rawBundle = EditorAssetBundle.RawAssetBundle( + manifest = originalBundle.manifest, + downloadDate = originalBundle.downloadDate + ) + + val encoded = json.encodeToString(rawBundle) + val decoded = json.decodeFromString(encoded) + + assertEquals(originalBundle.manifest.checksum, decoded.manifest.checksum) + assertEquals(originalBundle.downloadDate.time, decoded.downloadDate) + assertEquals(originalBundle.manifest.allowedBlockTypes, decoded.manifest.allowedBlockTypes) + } + + @Test + fun `Bundle preserves rawScripts through encoding`() { + val rawScripts = """""" + val manifest = createManifest(scripts = rawScripts) + val originalBundle = makeBundle(manifest = manifest) + + val rawBundle = EditorAssetBundle.RawAssetBundle( + manifest = originalBundle.manifest, + downloadDate = originalBundle.downloadDate + ) + + val encoded = json.encodeToString(rawBundle) + val decoded = json.decodeFromString(encoded) + + assertEquals(originalBundle.manifest.rawScripts, decoded.manifest.rawScripts) + } + + @Test + fun `Bundle preserves rawStyles through encoding`() { + val rawStyles = """""" + val manifest = createManifest(styles = rawStyles) + val originalBundle = makeBundle(manifest = manifest) + + val rawBundle = EditorAssetBundle.RawAssetBundle( + manifest = originalBundle.manifest, + downloadDate = originalBundle.downloadDate + ) + + val encoded = json.encodeToString(rawBundle) + val decoded = json.decodeFromString(encoded) + + assertEquals(originalBundle.manifest.rawStyles, decoded.manifest.rawStyles) + } + + // MARK: - File Initialization Tests + + @Test + fun `Bundle can be initialized from file`() { + val manifest = createManifest(blockTypes = listOf("core/paragraph")) + val originalBundle = makeBundle(manifest = manifest) + + // Create temp directory structure + val tempDir = createTempDir() + + // Write manifest.json + val manifestFile = File(tempDir, "manifest.json") + val rawBundle = EditorAssetBundle.RawAssetBundle( + manifest = originalBundle.manifest, + downloadDate = originalBundle.downloadDate + ) + val encoded = json.encodeToString(rawBundle) + manifestFile.writeText(encoded) + + // Initialize from file + val loadedBundle = EditorAssetBundle.fromFile(manifestFile) + + assertEquals(originalBundle.id, loadedBundle.id) + assertEquals(originalBundle.manifest.allowedBlockTypes, loadedBundle.manifest.allowedBlockTypes) + + // Clean up + tempDir.deleteRecursively() + } + + @Test(expected = Exception::class) + fun `Bundle initialization from invalid file throws error`() { + val invalidFile = File("/nonexistent/path/bundle.json") + EditorAssetBundle.fromFile(invalidFile) + } + + @Test(expected = Exception::class) + fun `Bundle initialization from invalid JSON throws error`() { + val tempFile = File.createTempFile("test", ".json") + tempFile.writeText("invalid json") + + try { + EditorAssetBundle.fromFile(tempFile) + } finally { + tempFile.delete() + } + } + + // MARK: - hasAssetData Tests + + @Test + fun `hasAssetData returns false for non-existent file`() { + val bundle = makeBundle() + val url = "https://example.com/nonexistent.js" + + assertFalse(bundle.hasAssetData(url)) + } + + @Test + fun `hasAssetData returns true when file exists at expected path`() { + // Create temp directory and file + val tempDir = createTempDir() + val assetDir = File(tempDir, "wp-content/plugins") + assetDir.mkdirs() + val assetFile = File(assetDir, "script.js") + assetFile.writeText("test") + + val bundle = makeBundle(bundleRoot = tempDir) + val url = "https://example.com/wp-content/plugins/script.js" + + assertTrue(bundle.hasAssetData(url)) + + // Clean up + tempDir.deleteRecursively() + } + + // MARK: - assetDataPath Tests + + @Test + fun `assetDataPath returns correct path based on URL path`() { + val tempDir = createTempDir() + val bundle = makeBundle(bundleRoot = tempDir) + + val url = "https://example.com/wp-content/plugins/script.js" + val result = bundle.assetDataPath(url) + + assertTrue(result.path.contains("/wp-content/plugins/script.js")) + + tempDir.deleteRecursively() + } + + @Test(expected = IllegalArgumentException::class) + fun `assetDataPath throws for path traversal attempt with parent directory`() { + val tempDir = createTempDir() + val bundle = makeBundle(bundleRoot = tempDir) + + try { + bundle.assetDataPath("https://example.com/../../../etc/passwd") + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun `assetDataPath allows valid nested paths`() { + val tempDir = createTempDir() + val bundle = makeBundle(bundleRoot = tempDir) + + val url = "https://example.com/wp-content/plugins/my-plugin/assets/js/script.js" + val result = bundle.assetDataPath(url) + + assertTrue(result.canonicalPath.startsWith(tempDir.canonicalPath)) + + tempDir.deleteRecursively() + } + + @Test + fun `assetDataPath normalizes paths with dot segments`() { + val tempDir = createTempDir() + val bundle = makeBundle(bundleRoot = tempDir) + + // This path has ./ which should be normalized but stay within bundle + val url = "https://example.com/wp-content/./plugins/script.js" + val result = bundle.assetDataPath(url) + + assertTrue(result.canonicalPath.startsWith(tempDir.canonicalPath)) + assertTrue(result.path.contains("plugins/script.js")) + + tempDir.deleteRecursively() + } + + // MARK: - assetData Tests + + @Test + fun `assetData returns data for existing file`() { + // Create temp directory and file + val tempDir = createTempDir() + val testContent = "console.log('test');" + val assetFile = File(tempDir, "script.js") + assetFile.writeText(testContent) + + val bundle = makeBundle(bundleRoot = tempDir) + + val requestUrl = "https://example.com/script.js" + val data = bundle.assetData(requestUrl) + + assertEquals(testContent, String(data)) + + // Clean up + tempDir.deleteRecursively() + } + + @Test(expected = Exception::class) + fun `assetData throws when file doesn't exist`() { + val bundle = makeBundle() + val url = "https://example.com/nonexistent.js" + bundle.assetData(url) + } + + // MARK: - Equatable Tests + + @Test + fun `Equal bundles are equal`() { + val manifest = createManifest(blockTypes = listOf("core/paragraph")) + val date = Date(1_700_000_000_000L) + + val bundle1 = makeBundle(manifest = manifest, downloadDate = date) + val bundle2 = makeBundle(manifest = manifest, downloadDate = date) + + assertEquals(bundle1, bundle2) + } + + @Test + fun `Bundles with different manifests are not equal`() { + val manifest1 = createManifest(blockTypes = listOf("core/paragraph")) + val manifest2 = createManifest(blockTypes = listOf("core/heading")) + + val bundle1 = makeBundle(manifest = manifest1) + val bundle2 = makeBundle(manifest = manifest2) + + assertNotEquals(bundle1, bundle2) + } + + @Test + fun `Bundles with different downloadDates are not equal`() { + val manifest = createManifest(blockTypes = listOf("core/paragraph")) + + val bundle1 = makeBundle(manifest = manifest, downloadDate = Date(1000)) + val bundle2 = makeBundle(manifest = manifest, downloadDate = Date(2000)) + + assertNotEquals(bundle1, bundle2) + } + + // MARK: - Integration Tests + + @Test + fun `Bundle round-trip through file system preserves all data`() { + val manifest = createManifest( + scripts = """""", + styles = """""", + blockTypes = listOf("core/paragraph", "core/heading", "jetpack/ai-assistant") + ) + val customDate = Date(1_700_000_000_000L) + val originalBundle = makeBundle(manifest = manifest, downloadDate = customDate) + + // Create temp directory + val tempDir = createTempDir() + + // Write to file + val tempFile = File(tempDir, "manifest.json") + val rawBundle = EditorAssetBundle.RawAssetBundle( + manifest = originalBundle.manifest, + downloadDate = originalBundle.downloadDate + ) + val encoded = json.encodeToString(rawBundle) + tempFile.writeText(encoded) + + // Read back + val loadedBundle = EditorAssetBundle.fromFile(tempFile) + + // Verify all data preserved + assertEquals(originalBundle.id, loadedBundle.id) + assertEquals(originalBundle.downloadDate, loadedBundle.downloadDate) + assertEquals(originalBundle.manifest.scripts, loadedBundle.manifest.scripts) + assertEquals(originalBundle.manifest.styles, loadedBundle.manifest.styles) + assertEquals(originalBundle.manifest.allowedBlockTypes, loadedBundle.manifest.allowedBlockTypes) + assertEquals(originalBundle.manifest.rawScripts, loadedBundle.manifest.rawScripts) + assertEquals(originalBundle.manifest.rawStyles, loadedBundle.manifest.rawStyles) + assertEquals(originalBundle.manifest.checksum, loadedBundle.manifest.checksum) + + // Clean up + tempDir.deleteRecursively() + } + + @Test + fun `Multiple bundles with different dates have same ID if same manifest`() { + val manifest = createManifest(blockTypes = listOf("core/paragraph")) + + val bundle1 = makeBundle(manifest = manifest, downloadDate = Date(1000)) + val bundle2 = makeBundle(manifest = manifest, downloadDate = Date(2000)) + + assertEquals(bundle1.id, bundle2.id) + assertNotEquals(bundle1.downloadDate, bundle2.downloadDate) + } + + // MARK: - EditorRepresentation Tests + + @Test + fun `setEditorRepresentation writes file to bundle root`() { + val tempDir = createTempDir() + + val bundle = makeBundle(bundleRoot = tempDir) + val representation = RemoteEditorAssetManifest.RawManifest( + scripts = """""", + styles = """""", + allowedBlockTypes = listOf("core/paragraph") + ) + + bundle.setEditorRepresentation(representation) + + val filePath = File(tempDir, "editor-representation.json") + assertTrue(filePath.exists()) + + tempDir.deleteRecursively() + } + + @Test + fun `getEditorRepresentation returns typed EditorRepresentation`() { + val tempDir = createTempDir() + + val bundle = makeBundle(bundleRoot = tempDir) + val original = RemoteEditorAssetManifest.RawManifest( + scripts = """""", + styles = """""", + allowedBlockTypes = listOf("core/paragraph", "core/heading") + ) + + bundle.setEditorRepresentation(original) + + val retrieved = bundle.getEditorRepresentation() + + assertEquals(original.scripts, retrieved.scripts) + assertEquals(original.styles, retrieved.styles) + assertEquals(original.allowedBlockTypes, retrieved.allowedBlockTypes) + + tempDir.deleteRecursively() + } + + @Test + fun `getEditorRepresentation returns map for JSON serialization`() { + val tempDir = createTempDir() + + val bundle = makeBundle(bundleRoot = tempDir) + val original = RemoteEditorAssetManifest.RawManifest( + scripts = """""", + styles = """""", + allowedBlockTypes = listOf("core/image") + ) + + bundle.setEditorRepresentation(original) + + val retrieved = bundle.getEditorRepresentationAsMap() + + assertEquals(original.scripts, retrieved["scripts"]) + assertEquals(original.styles, retrieved["styles"]) + assertEquals(original.allowedBlockTypes, retrieved["allowed_block_types"]) + + tempDir.deleteRecursively() + } + + @Test(expected = Exception::class) + fun `getEditorRepresentation throws when file does not exist`() { + val tempDir = createTempDir() + val bundle = makeBundle(bundleRoot = tempDir) + + try { + bundle.getEditorRepresentation() + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun `setEditorRepresentation overwrites existing file`() { + val tempDir = createTempDir() + + val bundle = makeBundle(bundleRoot = tempDir) + + val first = RemoteEditorAssetManifest.RawManifest( + scripts = "first", + styles = "first", + allowedBlockTypes = listOf("first") + ) + bundle.setEditorRepresentation(first) + + val second = RemoteEditorAssetManifest.RawManifest( + scripts = "second", + styles = "second", + allowedBlockTypes = listOf("second") + ) + bundle.setEditorRepresentation(second) + + val retrieved = bundle.getEditorRepresentation() + + assertEquals("second", retrieved.scripts) + assertEquals("second", retrieved.styles) + assertEquals(listOf("second"), retrieved.allowedBlockTypes) + + tempDir.deleteRecursively() + } + + @Test + fun `EditorRepresentation round-trip preserves all fields`() { + val tempDir = createTempDir() + + val bundle = makeBundle(bundleRoot = tempDir) + val original = RemoteEditorAssetManifest.RawManifest( + scripts = """""", + styles = """""", + allowedBlockTypes = listOf("core/paragraph", "core/heading", "core/image", "jetpack/ai-assistant") + ) + + bundle.setEditorRepresentation(original) + val retrieved = bundle.getEditorRepresentation() + + assertEquals(original, retrieved) + + tempDir.deleteRecursively() + } + + // MARK: - Test Helpers + + private fun makeBundle( + manifest: LocalEditorAssetManifest = LocalEditorAssetManifest.empty, + downloadDate: Date? = null, + bundleRoot: File = createTempDir() + ): EditorAssetBundle { + return if (downloadDate != null) { + EditorAssetBundle(manifest = manifest, downloadDate = downloadDate, bundleRoot = bundleRoot) + } else { + EditorAssetBundle(manifest = manifest, bundleRoot = bundleRoot) + } + } + + private fun createManifest( + scripts: String = "", + styles: String = "", + blockTypes: List = emptyList() + ): LocalEditorAssetManifest { + val blockTypesJson = blockTypes.joinToString(", ") { "\"$it\"" } + val jsonString = """ + { + "scripts": ${escapeJsonString(scripts)}, + "styles": ${escapeJsonString(styles)}, + "allowed_block_types": [$blockTypesJson] + } + """.trimIndent() + return createManifestFromJson(jsonString) + } + + private fun createManifestFromJson(jsonString: String): LocalEditorAssetManifest { + val remote = RemoteEditorAssetManifest.fromData(jsonString) + return LocalEditorAssetManifest.fromRemoteManifest(remote) + } + + private fun escapeJsonString(string: String): String { + val escaped = string + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + return "\"$escaped\"" + } + + private fun createTempDir(): File { + val tempDir = File(System.getProperty("java.io.tmpdir"), UUID.randomUUID().toString()) + tempDir.mkdirs() + return tempDir + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorCachePolicyTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorCachePolicyTest.kt new file mode 100644 index 00000000..40085c20 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorCachePolicyTest.kt @@ -0,0 +1,124 @@ +package org.wordpress.gutenberg.model + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class EditorCachePolicyTest { + + // Test Fixtures + private val referenceDate = Date(0) // Unix epoch + + // MARK: - ignore Policy Tests + + @Test + fun `ignore policy always returns false`() { + val policy = EditorCachePolicy.Ignore + + // Even a response cached just now should not be allowed + assertFalse(policy.allowsResponseWith(date = referenceDate, currentDate = referenceDate)) + } + + // MARK: - always Policy Tests + + @Test + fun `always policy always returns true`() { + val policy = EditorCachePolicy.Always + + // Even an extremely old response should be allowed + assertTrue(policy.allowsResponseWith(date = referenceDate, currentDate = referenceDate)) + } + + // MARK: - maxAge Policy Tests + + @Test + fun `maxAge policy returns true for response cached just now`() { + val policy = EditorCachePolicy.MaxAge(60_000) // 60 seconds + + assertTrue(policy.allowsResponseWith(date = referenceDate, currentDate = referenceDate)) + } + + @Test + fun `maxAge policy returns true for fresh response within interval`() { + val policy = EditorCachePolicy.MaxAge(60_000) // 60 seconds + val thirtySecondsAgo = Date(referenceDate.time - 30_000) + + assertTrue(policy.allowsResponseWith(date = thirtySecondsAgo, currentDate = referenceDate)) + } + + @Test + fun `maxAge policy returns false for expired response`() { + val policy = EditorCachePolicy.MaxAge(60_000) // 60 seconds + val twoMinutesAgo = Date(referenceDate.time - 120_000) + + assertFalse(policy.allowsResponseWith(date = twoMinutesAgo, currentDate = referenceDate)) + } + + @Test + fun `maxAge policy returns false for response just past expiry`() { + val policy = EditorCachePolicy.MaxAge(60_000) // 60 seconds + val sixtyOneSecondsAgo = Date(referenceDate.time - 61_000) + + assertFalse(policy.allowsResponseWith(date = sixtyOneSecondsAgo, currentDate = referenceDate)) + } + + @Test + fun `maxAge policy returns true for future-dated response`() { + val policy = EditorCachePolicy.MaxAge(60_000) // 60 seconds + val tenMinutesFromNow = Date(referenceDate.time + 600_000) + + // A future-dated response is definitely not expired + assertTrue(policy.allowsResponseWith(date = tenMinutesFromNow, currentDate = referenceDate)) + } + + // MARK: - maxAge Policy with Different Intervals + + @Test + fun `maxAge policy works with zero interval`() { + val policy = EditorCachePolicy.MaxAge(0) + + // With zero interval, only future-dated responses are valid + assertFalse(policy.allowsResponseWith(date = referenceDate, currentDate = referenceDate)) + assertFalse( + policy.allowsResponseWith( + date = Date(referenceDate.time - 1), + currentDate = referenceDate + ) + ) + } + + @Test + fun `maxAge policy works with one hour interval`() { + val policy = EditorCachePolicy.MaxAge(3_600_000) // 1 hour + + val thirtyMinutesAgo = Date(referenceDate.time - 1_800_000) + val twoHoursAgo = Date(referenceDate.time - 7_200_000) + + assertTrue(policy.allowsResponseWith(date = thirtyMinutesAgo, currentDate = referenceDate)) + assertFalse(policy.allowsResponseWith(date = twoHoursAgo, currentDate = referenceDate)) + } + + @Test + fun `maxAge policy works with one day interval`() { + val policy = EditorCachePolicy.MaxAge(86_400_000) // 24 hours + + val twelveHoursAgo = Date(referenceDate.time - 43_200_000) + val twoDaysAgo = Date(referenceDate.time - 172_800_000) + + assertTrue(policy.allowsResponseWith(date = twelveHoursAgo, currentDate = referenceDate)) + assertFalse(policy.allowsResponseWith(date = twoDaysAgo, currentDate = referenceDate)) + } + + @Test + fun `maxAge policy works with very large interval`() { + val oneYearMillis = 365L * 24 * 60 * 60 * 1000 + val policy = EditorCachePolicy.MaxAge(oneYearMillis) + + val sixMonthsAgo = Date(referenceDate.time - (182L * 24 * 60 * 60 * 1000)) + val twoYearsAgo = Date(referenceDate.time - (730L * 24 * 60 * 60 * 1000)) + + assertTrue(policy.allowsResponseWith(date = sixMonthsAgo, currentDate = referenceDate)) + assertFalse(policy.allowsResponseWith(date = twoYearsAgo, currentDate = referenceDate)) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt new file mode 100644 index 00000000..685be9bf --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -0,0 +1,862 @@ +package org.wordpress.gutenberg.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditorConfigurationBuilderTest { + + // MARK: - Test Fixtures + + companion object { + const val TEST_SITE_URL = "https://example.com" + const val TEST_API_ROOT = "https://example.com/wp-json" + const val TEST_POST_TYPE = "post" + } + + private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) + + // MARK: - Default Values Tests + + @Test + fun `Builder uses correct default values`() { + val config = builder().build() + + assertEquals("", config.title) + assertEquals("", config.content) + assertNull(config.postId) + assertEquals(TEST_POST_TYPE, config.postType) + assertFalse(config.themeStyles) + assertFalse(config.plugins) + assertFalse(config.hideTitle) + assertEquals(TEST_SITE_URL, config.siteURL) + assertEquals(TEST_API_ROOT, config.siteApiRoot) + assertArrayEquals(arrayOf(), config.siteApiNamespace) + assertArrayEquals(arrayOf(), config.namespaceExcludedPaths) + assertEquals("", config.authHeader) + assertNull(config.editorSettings) + assertEquals("en", config.locale) + assertEquals(emptyMap(), config.cookies) + assertFalse(config.enableAssetCaching) + assertEquals(emptySet(), config.cachedAssetHosts) + assertNull(config.editorAssetsEndpoint) + assertFalse(config.enableNetworkLogging) + assertFalse(config.enableOfflineMode) + } + + // MARK: - Individual Setter Tests + + @Test + fun `setTitle updates title`() { + val config = builder() + .setTitle("My Post Title") + .build() + + assertEquals("My Post Title", config.title) + } + + @Test + fun `setContent updates content`() { + val config = builder() + .setContent("

Hello world

") + .build() + + assertEquals("

Hello world

", config.content) + } + + @Test + fun `setPostId updates postId`() { + val config = builder() + .setPostId(123) + .build() + + assertEquals(123, config.postId) + } + + @Test + fun `setPostId with null clears postId`() { + val config = builder() + .setPostId(123) + .setPostId(null) + .build() + + assertNull(config.postId) + } + + @Test + fun `setPostType updates postType`() { + val config = builder() + .setPostType("page") + .build() + + assertEquals("page", config.postType) + } + + @Test + fun `setThemeStyles updates themeStyles`() { + val config = builder() + .setThemeStyles(true) + .build() + + assertTrue(config.themeStyles) + } + + @Test + fun `setPlugins updates plugins`() { + val config = builder() + .setPlugins(true) + .build() + + assertTrue(config.plugins) + } + + @Test + fun `setHideTitle updates hideTitle`() { + val config = builder() + .setHideTitle(true) + .build() + + assertTrue(config.hideTitle) + } + + @Test + fun `setSiteURL updates siteURL`() { + val newURL = "https://other.com" + val config = builder() + .setSiteURL(newURL) + .build() + + assertEquals(newURL, config.siteURL) + } + + @Test + fun `setSiteApiRoot updates siteApiRoot`() { + val newURL = "https://other.com/wp-json/v2" + val config = builder() + .setSiteApiRoot(newURL) + .build() + + assertEquals(newURL, config.siteApiRoot) + } + + @Test + fun `setSiteApiNamespace updates siteApiNamespace`() { + val namespaces = arrayOf("wp/v2", "wp/v3") + val config = builder() + .setSiteApiNamespace(namespaces) + .build() + + assertArrayEquals(namespaces, config.siteApiNamespace) + } + + @Test + fun `setNamespaceExcludedPaths updates namespaceExcludedPaths`() { + val paths = arrayOf("/oembed", "/batch") + val config = builder() + .setNamespaceExcludedPaths(paths) + .build() + + assertArrayEquals(paths, config.namespaceExcludedPaths) + } + + @Test + fun `setAuthHeader updates authHeader`() { + val config = builder() + .setAuthHeader("Bearer token123") + .build() + + assertEquals("Bearer token123", config.authHeader) + } + + @Test + fun `setEditorSettings updates editorSettings`() { + val settings = """{"colors":[]}""" + val config = builder() + .setEditorSettings(settings) + .build() + + assertEquals(settings, config.editorSettings) + } + + @Test + fun `setLocale updates locale`() { + val config = builder() + .setLocale("fr_FR") + .build() + + assertEquals("fr_FR", config.locale) + } + + @Test + fun `setCookies updates cookies`() { + val cookies = mapOf("session" to "abc123") + val config = builder() + .setCookies(cookies) + .build() + + assertEquals(cookies, config.cookies) + } + + @Test + fun `setEnableAssetCaching updates enableAssetCaching`() { + val config = builder() + .setEnableAssetCaching(true) + .build() + + assertTrue(config.enableAssetCaching) + } + + @Test + fun `setCachedAssetHosts updates cachedAssetHosts`() { + val hosts = setOf("example.com", "cdn.example.com") + val config = builder() + .setCachedAssetHosts(hosts) + .build() + + assertEquals(hosts, config.cachedAssetHosts) + } + + @Test + fun `setEditorAssetsEndpoint updates editorAssetsEndpoint`() { + val endpoint = "https://example.com/assets" + val config = builder() + .setEditorAssetsEndpoint(endpoint) + .build() + + assertEquals(endpoint, config.editorAssetsEndpoint) + } + + @Test + fun `setEnableNetworkLogging updates enableNetworkLogging`() { + val config = builder() + .setEnableNetworkLogging(true) + .build() + + assertTrue(config.enableNetworkLogging) + } + + @Test + fun `setEnableOfflineMode updates enableOfflineMode`() { + val config = builder() + .setEnableOfflineMode(true) + .build() + + assertTrue(config.enableOfflineMode) + } + + // MARK: - Method Chaining Tests + + @Test + fun `Builder supports method chaining`() { + val config = builder() + .setTitle("Chained Title") + .setContent("

Chained content

") + .setPostId(456) + .setPlugins(true) + .setThemeStyles(true) + .setLocale("de_DE") + .setEnableNetworkLogging(true) + .build() + + assertEquals("Chained Title", config.title) + assertEquals("

Chained content

", config.content) + assertEquals(456, config.postId) + assertTrue(config.plugins) + assertTrue(config.themeStyles) + assertEquals("de_DE", config.locale) + assertTrue(config.enableNetworkLogging) + } + + // MARK: - Multiple Builds Tests + + @Test + fun `Multiple builds from same builder produce equal configs`() { + val builder = builder().setTitle("Test") + + val config1 = builder.build() + val config2 = builder.build() + + assertEquals(config1, config2) + } + + // MARK: - toBuilder Tests + + @Test + fun `toBuilder preserves all configuration values`() { + val original = builder() + .setTitle("Round Trip Title") + .setContent("

Round trip content

") + .setPostId(999) + .setPostType("page") + .setThemeStyles(true) + .setPlugins(true) + .setHideTitle(true) + .setSiteURL(TEST_SITE_URL) + .setSiteApiRoot(TEST_API_ROOT) + .setSiteApiNamespace(arrayOf("wp/v2", "custom/v1")) + .setNamespaceExcludedPaths(arrayOf("/excluded")) + .setAuthHeader("Bearer roundtrip") + .setEditorSettings("""{"roundtrip":true}""") + .setLocale("es_ES") + .setCookies(mapOf("roundtrip" to "cookie")) + .setEnableAssetCaching(true) + .setCachedAssetHosts(setOf("cdn.example.com")) + .setEditorAssetsEndpoint("https://example.com/roundtrip-assets") + .setEnableNetworkLogging(true) + .setEnableOfflineMode(true) + .build() + + val rebuilt = original.toBuilder().build() + + assertEquals(original, rebuilt) + } + + @Test + fun `toBuilder allows modification of existing config`() { + val original = builder() + .setTitle("Original Title") + .setPostId(100) + .build() + + val modified = original.toBuilder() + .setTitle("Modified Title") + .build() + + assertEquals("Original Title", original.title) + assertEquals("Modified Title", modified.title) + assertEquals(100, modified.postId) + } + + @Test + fun `toBuilder preserves array values`() { + val namespaces = arrayOf("wp/v2", "wp/v3") + val excludedPaths = arrayOf("/oembed", "/batch") + + val original = builder() + .setSiteApiNamespace(namespaces) + .setNamespaceExcludedPaths(excludedPaths) + .build() + + val rebuilt = original.toBuilder().build() + + assertArrayEquals(namespaces, rebuilt.siteApiNamespace) + assertArrayEquals(excludedPaths, rebuilt.namespaceExcludedPaths) + } + + @Test + fun `toBuilder preserves collection values`() { + val cookies = mapOf("session" to "abc", "token" to "xyz") + val cachedHosts = setOf("cdn1.example.com", "cdn2.example.com") + + val original = builder() + .setCookies(cookies) + .setCachedAssetHosts(cachedHosts) + .build() + + val rebuilt = original.toBuilder().build() + + assertEquals(cookies, rebuilt.cookies) + assertEquals(cachedHosts, rebuilt.cachedAssetHosts) + } + + @Test + fun `toBuilder preserves nullable values when set`() { + val original = builder() + .setPostId(123) + .setPostType("post") + .setEditorSettings("""{"test":true}""") + .setEditorAssetsEndpoint("https://example.com/assets") + .build() + + val rebuilt = original.toBuilder().build() + + assertEquals(123, rebuilt.postId) + assertEquals("post", rebuilt.postType) + assertEquals("""{"test":true}""", rebuilt.editorSettings) + assertEquals("https://example.com/assets", rebuilt.editorAssetsEndpoint) + } + + @Test + fun `toBuilder preserves nullable values when null`() { + val original = builder() + .setPostId(null) + .setEditorSettings(null) + .setEditorAssetsEndpoint(null) + .build() + + val rebuilt = original.toBuilder().build() + + assertNull(rebuilt.postId) + assertNull(rebuilt.editorSettings) + assertNull(rebuilt.editorAssetsEndpoint) + } + + // MARK: - siteId Tests + + @Test + fun `siteId extracts host from siteURL`() { + val config = EditorConfiguration.builder("https://example.com/blog", TEST_API_ROOT) + .build() + + assertEquals("example.com", config.siteId) + } + + @Test + fun `siteId extracts host from siteURL with port`() { + val config = EditorConfiguration.builder("https://example.com:8080/blog", TEST_API_ROOT) + .build() + + assertEquals("example.com", config.siteId) + } + + @Test + fun `siteId extracts subdomain from siteURL`() { + val config = EditorConfiguration.builder("https://blog.example.com/posts", TEST_API_ROOT) + .build() + + assertEquals("blog.example.com", config.siteId) + } + + @Test + fun `siteId returns UUID for empty siteURL`() { + val config = EditorConfiguration.builder("", TEST_API_ROOT) + .build() + + // Should be a valid UUID format (36 characters with hyphens) + assertTrue(config.siteId.matches(Regex("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"))) + } + + @Test + fun `siteId returns UUID for invalid URL`() { + val config = EditorConfiguration.builder("not a valid url", TEST_API_ROOT) + .build() + + // Should be a valid UUID format + assertTrue(config.siteId.matches(Regex("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"))) + } + + @Test + fun `siteId is consistent for same URL`() { + val config = builder().build() + + val siteId1 = config.siteId + val siteId2 = config.siteId + + assertEquals(siteId1, siteId2) + } + + // MARK: - bundled() Tests + + @Test + fun `bundled returns configuration with offline mode enabled`() { + val config = EditorConfiguration.bundled() + + assertTrue(config.enableOfflineMode) + assertEquals("https://example.com", config.siteURL) + assertEquals("https://example.com/wp-json/", config.siteApiRoot) + assertEquals("post", config.postType) + } +} + +class EditorConfigurationTest { + + companion object { + const val TEST_SITE_URL = "https://example.com" + const val TEST_API_ROOT = "https://example.com/wp-json" + const val TEST_POST_TYPE = "post" + } + + private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) + + // MARK: - Equatable Tests + // Tests are ordered to match property declaration order in EditorConfiguration + + @Test + fun `Configurations with same values are equal`() { + val config1 = builder() + .setTitle("Test") + .setContent("

Content

") + .build() + + val config2 = builder() + .setTitle("Test") + .setContent("

Content

") + .build() + + assertEquals(config1, config2) + } + + @Test + fun `Configurations with different title are not equal`() { + val config1 = builder() + .setTitle("Title 1") + .build() + + val config2 = builder() + .setTitle("Title 2") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different content are not equal`() { + val config1 = builder() + .setContent("Content 1") + .build() + + val config2 = builder() + .setContent("Content 2") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different postId are not equal`() { + val config1 = builder() + .setPostId(1) + .build() + + val config2 = builder() + .setPostId(2) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different postType are not equal`() { + val config1 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "post") + .build() + + val config2 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "page") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different themeStyles are not equal`() { + val config1 = builder() + .setThemeStyles(true) + .build() + + val config2 = builder() + .setThemeStyles(false) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different plugins are not equal`() { + val config1 = builder() + .setPlugins(true) + .build() + + val config2 = builder() + .setPlugins(false) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different hideTitle are not equal`() { + val config1 = builder() + .setHideTitle(true) + .build() + + val config2 = builder() + .setHideTitle(false) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different siteURL are not equal`() { + val config1 = EditorConfiguration.builder("https://site1.com", TEST_API_ROOT) + .build() + + val config2 = EditorConfiguration.builder("https://site2.com", TEST_API_ROOT) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different siteApiRoot are not equal`() { + val config1 = EditorConfiguration.builder(TEST_SITE_URL, "https://example.com/wp-json/v1") + .build() + + val config2 = EditorConfiguration.builder(TEST_SITE_URL, "https://example.com/wp-json/v2") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different siteApiNamespace are not equal`() { + val config1 = builder() + .setSiteApiNamespace(arrayOf("wp/v2")) + .build() + + val config2 = builder() + .setSiteApiNamespace(arrayOf("wp/v3")) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different namespaceExcludedPaths are not equal`() { + val config1 = builder() + .setNamespaceExcludedPaths(arrayOf("/oembed")) + .build() + + val config2 = builder() + .setNamespaceExcludedPaths(arrayOf("/batch")) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different authHeader are not equal`() { + val config1 = builder() + .setAuthHeader("Bearer token1") + .build() + + val config2 = builder() + .setAuthHeader("Bearer token2") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different editorSettings are not equal`() { + val config1 = builder() + .setEditorSettings("""{"theme":"light"}""") + .build() + + val config2 = builder() + .setEditorSettings("""{"theme":"dark"}""") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different locale are not equal`() { + val config1 = builder() + .setLocale("en_US") + .build() + + val config2 = builder() + .setLocale("fr_FR") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different cookies are not equal`() { + val config1 = builder() + .setCookies(mapOf("session" to "abc")) + .build() + + val config2 = builder() + .setCookies(mapOf("session" to "xyz")) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different enableAssetCaching are not equal`() { + val config1 = builder() + .setEnableAssetCaching(true) + .build() + + val config2 = builder() + .setEnableAssetCaching(false) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different cachedAssetHosts are not equal`() { + val config1 = builder() + .setCachedAssetHosts(setOf("cdn1.example.com")) + .build() + + val config2 = builder() + .setCachedAssetHosts(setOf("cdn2.example.com")) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different editorAssetsEndpoint are not equal`() { + val config1 = builder() + .setEditorAssetsEndpoint("https://example.com/assets1") + .build() + + val config2 = builder() + .setEditorAssetsEndpoint("https://example.com/assets2") + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different enableNetworkLogging are not equal`() { + val config1 = builder() + .setEnableNetworkLogging(true) + .build() + + val config2 = builder() + .setEnableNetworkLogging(false) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different enableOfflineMode are not equal`() { + val config1 = builder() + .setEnableOfflineMode(true) + .build() + + val config2 = builder() + .setEnableOfflineMode(false) + .build() + + assertNotEquals(config1, config2) + } + + @Test + fun `Configurations with different siteId are not equal`() { + // siteId is derived from siteURL, so different URLs with different hosts produce different siteIds + val config1 = EditorConfiguration.builder("https://site1.example.com", TEST_API_ROOT) + .build() + + val config2 = EditorConfiguration.builder("https://site2.example.com", TEST_API_ROOT) + .build() + + assertNotEquals(config1.siteId, config2.siteId) + assertNotEquals(config1, config2) + } + + // MARK: - Hashable Tests + + @Test + fun `Identical configurations have same hash`() { + val config1 = builder() + .setTitle("Test") + .setContent("Content") + .build() + + val config2 = builder() + .setTitle("Test") + .setContent("Content") + .build() + + assertEquals(config1.hashCode(), config2.hashCode()) + } + + @Test + fun `Configurations can be used in Set`() { + val config1 = builder() + .setPostId(1) + .build() + + val config2 = builder() + .setPostId(2) + .build() + + val config3 = builder() + .setPostId(1) + .build() + + val set = setOf(config1, config2, config3) + + assertEquals(2, set.size) + } + + @Test + fun `Configuration can be used as map key`() { + val config1 = builder() + .setTitle("Key 1") + .build() + + val config2 = builder() + .setTitle("Key 2") + .build() + + val map = mutableMapOf() + map[config1] = "Value 1" + map[config2] = "Value 2" + + assertEquals("Value 1", map[config1]) + assertEquals("Value 2", map[config2]) + } + + // MARK: - Full Configuration Test + + @Test + fun `test EditorConfiguration builder sets all properties correctly`() { + val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", "post") + .setTitle("Test Title") + .setContent("Test Content") + .setPostId(123) + .setPostType("post") + .setThemeStyles(true) + .setPlugins(true) + .setHideTitle(false) + .setSiteURL("https://example.com") + .setSiteApiRoot("https://example.com/wp-json") + .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) + .setEnableOfflineMode(false) + .build() + + 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) + assertFalse(config.enableOfflineMode) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt new file mode 100644 index 00000000..6d8dc5f0 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt @@ -0,0 +1,444 @@ +package org.wordpress.gutenberg.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.wordpress.gutenberg.model.http.EditorHTTPHeaders +import org.wordpress.gutenberg.model.http.EditorURLResponse + +class EditorPreloadListTest { + + // MARK: - Test Fixtures + + private val json = Json { ignoreUnknownKeys = true } + + private fun makeResponse( + data: String = "{}", + headers: EditorHTTPHeaders = EditorHTTPHeaders() + ): EditorURLResponse { + return EditorURLResponse(data = data, responseHeaders = headers) + } + + private fun loadExpectedJSON(name: String): JsonElement { + val data = TestResources.loadResource("$name.json") + return json.parseToJsonElement(data) + } + + // MARK: - Initialization Tests + + @Test + fun `initializes with postID and postData`() { + val postData = makeResponse(data = """{"id":42}""") + val preloadList = EditorPreloadList( + postID = 42, + postData = postData, + postType = "post", + postTypeData = makeResponse(), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + assertEquals(42, preloadList.postID) + assertNotNull(preloadList.postData) + } + + @Test + fun `initializes with custom post type`() { + val preloadList = EditorPreloadList( + postType = "page", + postTypeData = makeResponse(), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + assertEquals("page", preloadList.postType) + } + + // MARK: - build() Exact Output Tests + + @Test + fun `build produces exact JSON for post type`() { + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(data = """{"slug":"post"}"""), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val expected = loadExpectedJSON("preload-list-post-type") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build produces exact JSON for page type`() { + val preloadList = EditorPreloadList( + postType = "page", + postTypeData = makeResponse(data = """{"slug":"page"}"""), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val expected = loadExpectedJSON("preload-list-page-type") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build produces exact JSON with post data included`() { + val preloadList = EditorPreloadList( + postID = 123, + postData = makeResponse(data = """{"id":123,"title":"Test"}"""), + postType = "post", + postTypeData = makeResponse(data = "{}"), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val expected = loadExpectedJSON("preload-list-with-post-data") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build produces exact JSON with Accept header`() { + val headers = EditorHTTPHeaders(mapOf("Accept" to "application/json")) + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(data = "{}", headers = headers), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val expected = loadExpectedJSON("preload-list-with-accept-header") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build produces exact JSON with Link header`() { + val headers = EditorHTTPHeaders(mapOf("Link" to """; rel="next"""")) + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(data = "{}", headers = headers), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val expected = loadExpectedJSON("preload-list-with-link-header") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build produces exact JSON with multiple headers sorted alphabetically`() { + val headers = EditorHTTPHeaders( + mapOf("Link" to "", "Accept" to "application/json") + ) + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(data = "{}", headers = headers), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val expected = loadExpectedJSON("preload-list-with-multiple-headers") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build excludes post when postID is nil`() { + val preloadList = EditorPreloadList( + postID = null, + postData = null, + postType = "post", + postTypeData = makeResponse(), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + val expected = loadExpectedJSON("preload-list-empty-body") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build excludes post when postData is nil`() { + val preloadList = EditorPreloadList( + postID = 42, + postData = null, + postType = "post", + postTypeData = makeResponse(), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + val expected = loadExpectedJSON("preload-list-empty-body") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + @Test + fun `build produces exact JSON for custom_post_type`() { + val preloadList = EditorPreloadList( + postType = "custom_post_type", + postTypeData = makeResponse(), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + val expected = loadExpectedJSON("preload-list-custom-post-type") + val actual = preloadList.build() + assertEquals(expected, actual) + } + + // MARK: - build(formatted:) String Output Tests + + @Test + fun `build(formatted = false) returns valid JSON string`() { + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(data = """{"slug":"post"}"""), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val jsonString = preloadList.build(formatted = false) + val parsed = json.parseToJsonElement(jsonString) + assertTrue(parsed.toString().isNotEmpty()) + } + + @Test + fun `build(formatted = true) returns valid JSON string`() { + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(data = """{"slug":"post"}"""), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val jsonString = preloadList.build(formatted = true) + val parsed = json.parseToJsonElement(jsonString) + assertTrue(parsed.toString().isNotEmpty()) + } + + @Test + fun `build(formatted = true) produces pretty-printed JSON`() { + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(data = "{}"), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val jsonString = preloadList.build(formatted = true) + // Keys are sorted alphabetically to ensure same behaviour between platforms + val expected = """ + |{ + | "/wp/v2/themes?context=edit&status=active": { + | "body": [], + | "headers": {} + | }, + | "/wp/v2/types/post?context=edit": { + | "body": {}, + | "headers": {} + | }, + | "/wp/v2/types?context=view": { + | "body": {}, + | "headers": {} + | }, + | "OPTIONS": { + | "/wp/v2/settings": { + | "body": {}, + | "headers": {} + | } + | } + |} + """.trimMargin() + assertEquals(expected, jsonString) + } + + @Test + fun `build(formatted) produces same JSON regardless of formatting`() { + val preloadList = EditorPreloadList( + postID = 123, + postData = makeResponse(data = """{"id":123,"title":"Test"}"""), + postType = "post", + postTypeData = makeResponse(data = """{"slug":"post"}"""), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val unformatted = preloadList.build(formatted = false) + val formatted = preloadList.build(formatted = true) + + val parsedUnformatted = json.parseToJsonElement(unformatted) + val parsedFormatted = json.parseToJsonElement(formatted) + + assertEquals(parsedUnformatted, parsedFormatted) + } + + @Test + fun `build(formatted) matches build() JSON object`() { + val preloadList = EditorPreloadList( + postID = 123, + postData = makeResponse(data = """{"id":123,"title":"Test"}"""), + postType = "post", + postTypeData = makeResponse(data = """{"slug":"post"}"""), + postTypesData = makeResponse(data = "{}"), + activeThemeData = makeResponse(data = "[]"), + settingsOptionsData = makeResponse(data = "{}") + ) + + val jsonObject = preloadList.build() + val jsonString = preloadList.build(formatted = false) + val parsedString = json.parseToJsonElement(jsonString) + + assertEquals(jsonObject, parsedString) + } + + // MARK: - Header Filtering Tests + + @Test + fun `filters out Content-Type header`() { + val headers = EditorHTTPHeaders( + mapOf("Accept" to "application/json", "Content-Type" to "application/json") + ) + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(headers = headers), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + assertEquals("application/json", preloadList.postTypeData.responseHeaders["Accept"]) + assertNull(preloadList.postTypeData.responseHeaders["Content-Type"]) + } + + @Test + fun `filters out X-Custom header`() { + val headers = EditorHTTPHeaders( + mapOf("Accept" to "application/json", "X-Custom" to "value") + ) + val preloadList = EditorPreloadList( + postType = "post", + postTypeData = makeResponse(headers = headers), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + assertEquals("application/json", preloadList.postTypeData.responseHeaders["Accept"]) + assertNull(preloadList.postTypeData.responseHeaders["X-Custom"]) + } + + @Test + fun `filters headers for postData`() { + val headers = EditorHTTPHeaders( + mapOf("Accept" to "application/json", "Content-Type" to "application/json") + ) + val preloadList = EditorPreloadList( + postID = 1, + postData = makeResponse(headers = headers), + postType = "post", + postTypeData = makeResponse(), + postTypesData = makeResponse(), + activeThemeData = makeResponse(), + settingsOptionsData = makeResponse() + ) + + assertEquals("application/json", preloadList.postData?.responseHeaders?.get("Accept")) + assertNull(preloadList.postData?.responseHeaders?.get("Content-Type")) + } + + // MARK: - Equatable Tests + + @Test + fun `two preload lists with same data are equal`() { + val response = makeResponse(data = """{"test":true}""") + val preloadList1 = EditorPreloadList( + postType = "post", + postTypeData = response, + postTypesData = response, + activeThemeData = response, + settingsOptionsData = response + ) + val preloadList2 = EditorPreloadList( + postType = "post", + postTypeData = response, + postTypesData = response, + activeThemeData = response, + settingsOptionsData = response + ) + + assertEquals(preloadList1, preloadList2) + } + + @Test + fun `preload lists with different post types are not equal`() { + val response = makeResponse() + val preloadList1 = EditorPreloadList( + postType = "post", + postTypeData = response, + postTypesData = response, + activeThemeData = response, + settingsOptionsData = response + ) + val preloadList2 = EditorPreloadList( + postType = "page", + postTypeData = response, + postTypesData = response, + activeThemeData = response, + settingsOptionsData = response + ) + + assertNotEquals(preloadList1, preloadList2) + } + + @Test + fun `preload lists with different postID are not equal`() { + val response = makeResponse() + val preloadList1 = EditorPreloadList( + postID = 1, + postData = response, + postType = "post", + postTypeData = response, + postTypesData = response, + activeThemeData = response, + settingsOptionsData = response + ) + val preloadList2 = EditorPreloadList( + postID = 2, + postData = response, + postType = "post", + postTypeData = response, + postTypesData = response, + activeThemeData = response, + settingsOptionsData = response + ) + + assertNotEquals(preloadList1, preloadList2) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorProgressTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorProgressTest.kt new file mode 100644 index 00000000..ddd16de5 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorProgressTest.kt @@ -0,0 +1,27 @@ +package org.wordpress.gutenberg.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EditorProgressTest { + + @Test + fun `reports zero when there are no completed items`() { + assertEquals(0.0, EditorProgress(completed = 0, total = 5).fractionCompleted, 0.0) + } + + @Test + fun `reports zero when there are no total items`() { + assertEquals(0.0, EditorProgress(completed = 5, total = 0).fractionCompleted, 0.0) + } + + @Test + fun `reports the correct percentage when there are both completed and total items`() { + assertEquals(1.0, EditorProgress(completed = 5, total = 5).fractionCompleted, 0.0) + } + + @Test + fun `reports a maximum of 1_0 when there are more completed items than total items`() { + assertEquals(1.0, EditorProgress(completed = 10, total = 5).fractionCompleted, 0.0) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorSettingsTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorSettingsTest.kt new file mode 100644 index 00000000..c4a1ef4d --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorSettingsTest.kt @@ -0,0 +1,175 @@ +package org.wordpress.gutenberg.model + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditorSettingsTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `throws when JSON is invalid`() { + val invalidJSON = "not valid json" + + assertThrows(Exception::class.java, { + EditorSettings.fromData(invalidJSON) + }) + } + + // MARK: - themeStyles Tests + + @Test + fun `themeStyles is empty when styles array is empty`() { + val jsonString = """{"styles": []}""" + val settings = EditorSettings.fromData(jsonString) + assertTrue(settings.themeStyles.isEmpty()) + } + + @Test + fun `themeStyles extracts single css value`() { + val jsonString = """{"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}]}""" + val settings = EditorSettings.fromData(jsonString) + assertEquals("body { color: red; }", settings.themeStyles) + } + + @Test + fun `themeStyles joins multiple css values with newlines`() { + val jsonString = """{"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}, {"css": "h1 { font-size: 2em; }", "isGlobalStyles": false}]}""" + val settings = EditorSettings.fromData(jsonString) + assertEquals("body { color: red; }\nh1 { font-size: 2em; }", settings.themeStyles) + } + + @Test + fun `themeStyles skips styles with null css`() { + val jsonString = """{"styles": [{"css": null, "isGlobalStyles": true}, {"css": "h1 { font-size: 2em; }", "isGlobalStyles": false}]}""" + val settings = EditorSettings.fromData(jsonString) + assertEquals("h1 { font-size: 2em; }", settings.themeStyles) + } + + @Test + fun `themeStyles skips styles without css key`() { + val jsonString = """{"styles": [{"isGlobalStyles": true}, {"css": "h1 { font-size: 2em; }", "isGlobalStyles": false}]}""" + val settings = EditorSettings.fromData(jsonString) + assertEquals("h1 { font-size: 2em; }", settings.themeStyles) + } + + @Test + fun `themeStyles is empty when styles key is missing`() { + val jsonString = """{"otherKey": "value"}""" + val settings = EditorSettings.fromData(jsonString) + assertTrue(settings.themeStyles.isEmpty()) + } + + // MARK: - Codable Tests + + @Test + fun `EditorSettings can be encoded and decoded`() { + val jsonString = """{"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}]}""" + val original = EditorSettings.fromData(jsonString) + + val encoded = json.encodeToString(original) + val decoded = json.decodeFromString(encoded) + + assertEquals(original.stringValue, decoded.stringValue) + assertEquals(original.themeStyles, decoded.themeStyles) + } + + @Test + fun `EditorSettings preserves themeStyles through encoding round-trip`() { + val jsonString = """{"styles": [{"css": ".theme-class { background: blue; }", "isGlobalStyles": true}, {"css": ".another { margin: 10px; }", "isGlobalStyles": false}]}""" + val original = EditorSettings.fromData(jsonString) + + val encoded = json.encodeToString(original) + val decoded = json.decodeFromString(encoded) + + assertEquals(".theme-class { background: blue; }\n.another { margin: 10px; }", decoded.themeStyles) + } + + // MARK: - Edge Cases + + @Test + fun `themeStyles handles empty css string`() { + val jsonString = """{"styles": [{"css": "", "isGlobalStyles": true}]}""" + val settings = EditorSettings.fromData(jsonString) + assertEquals("", settings.themeStyles) + } + + @Test + fun `themeStyles handles css with special characters`() { + val jsonString = """{"styles": [{"css": ".class::before { content: '\u003C'; }", "isGlobalStyles": true}]}""" + val settings = EditorSettings.fromData(jsonString) + assertTrue(settings.themeStyles.contains("::before")) + } + + @Test + fun `themeStyles handles multiline css`() { + val jsonString = """{"styles": [{"css": "body {\n color: red;\n background: blue;\n}", "isGlobalStyles": true}]}""" + val settings = EditorSettings.fromData(jsonString) + assertTrue(settings.themeStyles.contains("color: red")) + assertTrue(settings.themeStyles.contains("background: blue")) + } +} + +// MARK: - InternalEditorSettings Tests + +class InternalEditorSettingsTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `decodes styles array correctly`() { + val jsonString = """{"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}]}""" + val settings = json.decodeFromString(jsonString) + assertEquals(1, settings.styles.size) + assertEquals("body { color: red; }", settings.styles[0].css) + assertEquals(true, settings.styles[0].isGlobalStyles) + } + + @Test + fun `decodes multiple styles`() { + val jsonString = """{"styles": [{"css": "a", "isGlobalStyles": true}, {"css": "b", "isGlobalStyles": false}]}""" + val settings = json.decodeFromString(jsonString) + assertEquals(2, settings.styles.size) + assertEquals("a", settings.styles[0].css) + assertEquals("b", settings.styles[1].css) + assertEquals(true, settings.styles[0].isGlobalStyles) + assertEquals(false, settings.styles[1].isGlobalStyles) + } + + @Test + fun `decodes null css value`() { + val jsonString = """{"styles": [{"css": null, "isGlobalStyles": true}]}""" + val settings = json.decodeFromString(jsonString) + assertEquals(1, settings.styles.size) + assertNull(settings.styles[0].css) + } + + @Test + fun `decodes empty styles array`() { + val jsonString = """{"styles": []}""" + val settings = json.decodeFromString(jsonString) + assertTrue(settings.styles.isEmpty()) + } + + @Test + fun `parses real editor settings test case`() { + val data = TestResources.loadResource("editor-settings-test-case-1.json") + val settings = json.decodeFromString(data) + + // The test file should have multiple styles + assertTrue(settings.styles.isNotEmpty()) + + // At least one style should have CSS content + val stylesWithCSS = settings.styles.filter { !it.css.isNullOrEmpty() } + assertTrue(stylesWithCSS.isNotEmpty()) + + // Verify isGlobalStyles is parsed + val globalStyles = settings.styles.filter { it.isGlobalStyles } + assertTrue(globalStyles.isNotEmpty()) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt new file mode 100644 index 00000000..91c919db --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -0,0 +1,255 @@ +package org.wordpress.gutenberg.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.wordpress.gutenberg.model.http.EditorHTTPHeaders +import org.wordpress.gutenberg.model.http.EditorURLResponse + +class GBKitGlobalTest { + + companion object { + private const val TEST_SITE_URL = "https://example.com" + private const val TEST_API_ROOT = "https://example.com/wp-json" + } + + // MARK: - Test Helpers + + private fun makeDependencies(): EditorDependencies { + return EditorDependencies( + editorSettings = EditorSettings.undefined, + assetBundle = EditorAssetBundle.empty, + preloadList = makePreloadList() + ) + } + + private fun makePreloadList(): EditorPreloadList { + return EditorPreloadList( + postType = "post", + postTypeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), + postTypesData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), + activeThemeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), + settingsOptionsData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()) + ) + } + + private fun makeConfiguration( + postId: Int? = null, + title: String? = null, + content: String? = null, + siteURL: String = TEST_SITE_URL, + postType: String = "post", + shouldUsePlugins: Boolean = true, + shouldUseThemeStyles: Boolean = true + ): EditorConfiguration { + return EditorConfiguration.builder(siteURL, TEST_API_ROOT, postType) + .setPostId(postId) + .setTitle(title ?: "") + .setContent(content ?: "") + .setPlugins(shouldUsePlugins) + .setThemeStyles(shouldUseThemeStyles) + .setAuthHeader("Bearer test-token") + .build() + } + + // MARK: - Initialization + + @Test + fun `initializes with configuration and dependencies`() { + val configuration = makeConfiguration() + val dependencies = makeDependencies() + val global = GBKitGlobal.fromConfiguration(configuration, dependencies) + assertEquals(TEST_SITE_URL, global.siteURL) + } + + // MARK: - Property Mapping + + @Test + fun `maps siteURL from configuration`() { + val siteURL = "https://my-wordpress-site.com" + val configuration = makeConfiguration(siteURL = siteURL) + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals(siteURL, global.siteURL) + } + + @Test + fun `maps siteApiRoot from configuration`() { + val configuration = makeConfiguration() + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals(TEST_API_ROOT, global.siteApiRoot) + } + + @Test + fun `maps themeStyles from configuration`() { + val withThemeStyles = makeConfiguration(shouldUseThemeStyles = true) + val withoutThemeStyles = makeConfiguration(shouldUseThemeStyles = false) + + val globalWith = GBKitGlobal.fromConfiguration(withThemeStyles, makeDependencies()) + val globalWithout = GBKitGlobal.fromConfiguration(withoutThemeStyles, makeDependencies()) + + assertTrue(globalWith.themeStyles) + assertFalse(globalWithout.themeStyles) + } + + @Test + fun `maps plugins from configuration`() { + val withPlugins = makeConfiguration(shouldUsePlugins = true) + val withoutPlugins = makeConfiguration(shouldUsePlugins = false) + + val globalWith = GBKitGlobal.fromConfiguration(withPlugins, makeDependencies()) + val globalWithout = GBKitGlobal.fromConfiguration(withoutPlugins, makeDependencies()) + + assertTrue(globalWith.plugins) + assertFalse(globalWithout.plugins) + } + + @Test + fun `maps postID to post id`() { + val withPostID = makeConfiguration(postId = 42) + val withoutPostID = makeConfiguration(postId = null) + + val globalWith = GBKitGlobal.fromConfiguration(withPostID, makeDependencies()) + val globalWithout = GBKitGlobal.fromConfiguration(withoutPostID, makeDependencies()) + + assertEquals(42, globalWith.post.id) + assertEquals(-1, globalWithout.post.id) + } + + @Test + fun `maps title with percent encoding`() { + val configuration = makeConfiguration(title = "Hello World") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals("Hello%20World", global.post.title) + } + + @Test + fun `maps content with percent encoding`() { + val configuration = makeConfiguration( + content = "

Test

" + ) + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertTrue(global.post.content.contains("%")) + assertFalse(global.post.content.contains("<")) + } + + // MARK: - toJsonString() + + @Test + fun `toJsonString produces valid JSON`() { + val configuration = makeConfiguration() + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + + val jsonString = global.toJsonString() + val decoded = Json.parseToJsonElement(jsonString) + + assertTrue(decoded.toString().isNotEmpty()) + } + + @Test + fun `toJsonString includes all required fields`() { + val configuration = makeConfiguration(postId = 123, title = "Test", content = "Content") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + + val jsonString = global.toJsonString() + + assertTrue(jsonString.contains("siteURL")) + assertTrue(jsonString.contains("siteApiRoot")) + assertTrue(jsonString.contains("themeStyles")) + assertTrue(jsonString.contains("plugins")) + assertTrue(jsonString.contains("post")) + assertTrue(jsonString.contains("locale")) + assertTrue(jsonString.contains("logLevel")) + } + + @Test + fun `toJsonString round-trips through serialization`() { + val configuration = makeConfiguration(postId = 99, title = "Round Trip", content = "Test content") + val original = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + + val jsonString = original.toJsonString() + val decoded = Json.decodeFromString(jsonString) + + assertEquals(original.siteURL, decoded.siteURL) + assertEquals(original.post.id, decoded.post.id) + assertEquals(original.post.title, decoded.post.title) + assertEquals(original.themeStyles, decoded.themeStyles) + assertEquals(original.plugins, decoded.plugins) + } + + // MARK: - Special Characters + + @Test + fun `handles unicode in title`() { + val configuration = makeConfiguration(title = "日本語タイトル") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + + val jsonString = global.toJsonString() + assertTrue(jsonString.isNotEmpty()) + + val decoded = Json.decodeFromString(jsonString) + assertEquals(global.post.title, decoded.post.title) + } + + @Test + fun `handles emoji in content`() { + val configuration = makeConfiguration(content = "Hello 👋 World 🌍") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + + val jsonString = global.toJsonString() + assertTrue(jsonString.isNotEmpty()) + + val decoded = Json.decodeFromString(jsonString) + assertEquals(global.post.content, decoded.post.content) + } + + @Test + fun `handles special HTML characters in content`() { + val configuration = makeConfiguration(content = "") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + + val jsonString = global.toJsonString() + // Should be percent-encoded, not raw HTML + assertFalse(jsonString.contains("", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(listOf("https://example.com/app.js", "https://example.com/vendor.js"), links) + } + + @Test + fun `parses stylesheet href attributes`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(listOf("https://example.com/main.css", "https://example.com/theme.css"), links) + } + + @Test + fun `parses both scripts and styles`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(listOf("https://example.com/app.js", "https://example.com/style.css"), links) + } + + // MARK: - assetUrls - Protocol-Relative URLs + + @Test + fun `resolves protocol-relative URLs with default scheme`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(listOf("https://cdn.example.com/script.js", "https://cdn.example.com/style.css"), links) + } + + @Test + fun `uses https as default scheme when none specified`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(listOf("https://cdn.example.com/script.js"), links) + } + + // MARK: - assetUrls - Filtering + + @Test + fun `ignores inline scripts without src`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(listOf("https://example.com/app.js"), links) + } + + @Test + fun `ignores link tags without stylesheet rel`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(listOf("https://example.com/style.css"), links) + } + + @Test + fun `returns empty list for empty scripts and styles`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = createManifestFromJson(json) + val links = manifest.assetUrls + + assertEquals(emptyList(), links) + } + + // MARK: - Empty Manifest + + @Test + fun `empty manifest is empty`() { + assertTrue(LocalEditorAssetManifest.empty.scripts.isEmpty()) + assertTrue(LocalEditorAssetManifest.empty.styles.isEmpty()) + assertTrue(LocalEditorAssetManifest.empty.allowedBlockTypes.isEmpty()) + assertTrue(LocalEditorAssetManifest.empty.rawStyles.isEmpty()) + assertTrue(LocalEditorAssetManifest.empty.rawScripts.isEmpty()) + assertTrue(LocalEditorAssetManifest.empty.assetUrls.isEmpty()) + } + + // MARK: - Test Helpers + + private fun createManifestFromJson(jsonString: String): LocalEditorAssetManifest { + val remote = RemoteEditorAssetManifest.fromData(jsonString) + return LocalEditorAssetManifest.fromRemoteManifest(remote) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/RemoteEditorAssetManifestTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/RemoteEditorAssetManifestTest.kt new file mode 100644 index 00000000..eee86d11 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/RemoteEditorAssetManifestTest.kt @@ -0,0 +1,141 @@ +package org.wordpress.gutenberg.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemoteEditorAssetManifestTest { + + // MARK: - Decoding + + @Test + fun `decodes from valid JSON`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": ["core/paragraph", "core/heading"] + } + """.trimIndent() + + val manifest = RemoteEditorAssetManifest.fromData(json) + + assertEquals("""""", manifest.scripts) + assertEquals("""""", manifest.styles) + assertEquals(listOf("core/paragraph", "core/heading"), manifest.allowedBlockTypes) + } + + @Test + fun `decodes empty allowed block types`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = RemoteEditorAssetManifest.fromData(json) + + assertTrue(manifest.allowedBlockTypes.isEmpty()) + } + + @Test + fun `generates checksum from data`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = RemoteEditorAssetManifest.fromData(json) + + assertTrue(manifest.checksum.isNotEmpty()) + } + + @Test + fun `same data produces same checksum`() { + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest1 = RemoteEditorAssetManifest.fromData(json) + val manifest2 = RemoteEditorAssetManifest.fromData(json) + + assertEquals(manifest1.checksum, manifest2.checksum) + } + + @Test + fun `different data produces different checksum`() { + val json1 = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val json2 = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest1 = RemoteEditorAssetManifest.fromData(json1) + val manifest2 = RemoteEditorAssetManifest.fromData(json2) + + assertNotEquals(manifest1.checksum, manifest2.checksum) + } + + @Test + fun `preserves raw scripts content`() { + val scriptsContent = """""" + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = RemoteEditorAssetManifest.fromData(json) + + assertEquals(scriptsContent, manifest.scripts) + } + + @Test + fun `preserves raw styles content`() { + val stylesContent = """""" + val json = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + val manifest = RemoteEditorAssetManifest.fromData(json) + + assertEquals(stylesContent, manifest.styles) + } + + // MARK: - Integration Tests + + @Test + fun `successfully decodes test case 1`() { + val json = TestResources.loadResource("editor-asset-manifest-test-case-1.json") + val manifest = RemoteEditorAssetManifest.fromData(json) + + assertTrue(manifest.scripts.isNotEmpty()) + assertTrue(manifest.checksum.isNotEmpty()) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/TestResources.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/TestResources.kt new file mode 100644 index 00000000..da3f2bc2 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/TestResources.kt @@ -0,0 +1,36 @@ +package org.wordpress.gutenberg.model + +import java.io.File + +/** + * Helper object to access shared test resources from the iOS test folder. + */ +object TestResources { + private val resourcesDir: File by lazy { + // Navigate from android module to the shared iOS test resources + // Try multiple strategies to find the resources + val possiblePaths = listOf( + // When running from android directory + File(System.getProperty("user.dir"), "../ios/Tests/GutenbergKitTests/Resources"), + // When running from project root + File(System.getProperty("user.dir"), "ios/Tests/GutenbergKitTests/Resources"), + // When running from Gutenberg module directory + File(System.getProperty("user.dir"), "../../ios/Tests/GutenbergKitTests/Resources") + ) + + possiblePaths.firstOrNull { it.exists() && it.isDirectory } + ?: throw IllegalStateException( + "Could not find iOS test resources. Tried: ${possiblePaths.map { it.absolutePath }}" + ) + } + + fun loadResource(name: String): String { + val file = File(resourcesDir, name) + require(file.exists()) { "Test resource not found: ${file.absolutePath}" } + return file.readText() + } + + fun resourceExists(name: String): Boolean { + return File(resourcesDir, name).exists() + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorHTTPHeadersTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorHTTPHeadersTest.kt new file mode 100644 index 00000000..55853994 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorHTTPHeadersTest.kt @@ -0,0 +1,335 @@ +package org.wordpress.gutenberg.model.http + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class EditorHTTPHeadersTest { + + // MARK: - Initialization Tests + + @Test + fun `Empty initializer creates empty headers`() { + val headers = EditorHTTPHeaders() + + assertNull(headers["Content-Type"]) + assertNull(headers["Accept"]) + } + + @Test + fun `Map initializer creates headers with values`() { + val headers = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html" + ) + ) + + assertEquals("application/json", headers["Content-Type"]) + assertEquals("text/html", headers["Accept"]) + } + + @Test + fun `Map initializer handles empty map`() { + val headers = EditorHTTPHeaders(emptyMap()) + + assertNull(headers["Content-Type"]) + } + + @Test + fun `Map initializer handles single entry`() { + val headers = EditorHTTPHeaders(mapOf("X-Custom-Header" to "custom-value")) + + assertEquals("custom-value", headers["X-Custom-Header"]) + } + + // MARK: - Subscript Tests + + @Test + fun `Subscript getter returns value for existing key`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + + assertEquals("application/json", headers["Content-Type"]) + } + + @Test + fun `Subscript getter returns nil for missing key`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + + assertNull(headers["Accept"]) + } + + @Test + fun `Subscript setter adds new value`() { + val headers = EditorHTTPHeaders() + + headers["Content-Type"] = "application/json" + + assertEquals("application/json", headers["Content-Type"]) + } + + @Test + fun `Subscript setter updates existing value`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "text/plain")) + + headers["Content-Type"] = "application/json" + + assertEquals("application/json", headers["Content-Type"]) + } + + @Test + fun `Subscript setter removes value when set to null`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + + headers["Content-Type"] = null + + assertNull(headers["Content-Type"]) + } + + @Test + fun `Subscript is case-insensitive`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + + assertEquals("application/json", headers["Content-Type"]) + assertEquals("application/json", headers["content-type"]) + assertEquals("application/json", headers["CONTENT-TYPE"]) + } + + @Test + fun `Subscript setter is case-insensitive`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "text/plain")) + + // Setting with different case should update the same key + headers["CONTENT-TYPE"] = "application/json" + + assertEquals("application/json", headers["Content-Type"]) + assertEquals("application/json", headers["content-type"]) + } + + @Test + fun `Headers with same keys but different case are equal`() { + val headers1 = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + val headers2 = EditorHTTPHeaders(mapOf("content-type" to "application/json")) + + assertEquals(headers1, headers2) + } + + // MARK: - Equatable Tests + + @Test + fun `Equal headers are equal`() { + val headers1 = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html" + ) + ) + val headers2 = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html" + ) + ) + + assertEquals(headers1, headers2) + } + + @Test + fun `Empty headers are equal`() { + val headers1 = EditorHTTPHeaders() + val headers2 = EditorHTTPHeaders() + + assertEquals(headers1, headers2) + } + + @Test + fun `Headers with different values are not equal`() { + val headers1 = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + val headers2 = EditorHTTPHeaders(mapOf("Content-Type" to "text/plain")) + + assertNotEquals(headers1, headers2) + } + + @Test + fun `Headers with different keys are not equal`() { + val headers1 = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + val headers2 = EditorHTTPHeaders(mapOf("Accept" to "application/json")) + + assertNotEquals(headers1, headers2) + } + + @Test + fun `Headers with different counts are not equal`() { + val headers1 = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + val headers2 = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html" + ) + ) + + assertNotEquals(headers1, headers2) + } + + // MARK: - Codable Tests + + @Test + fun `Headers can be encoded and decoded`() { + val original = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html", + "X-Custom-Header" to "custom-value" + ) + ) + + val encoded = Json.encodeToString(original) + val decoded = Json.decodeFromString(encoded) + + assertEquals(original, decoded) + } + + @Test + fun `Empty headers can be encoded and decoded`() { + val original = EditorHTTPHeaders() + + val encoded = Json.encodeToString(original) + val decoded = Json.decodeFromString(encoded) + + assertEquals(original, decoded) + } + + @Test + fun `Headers preserve values through encoding round-trip`() { + val original = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json; charset=utf-8", + "Authorization" to "Bearer token123", + "X-Request-ID" to "550e8400-e29b-41d4-a716-446655440000" + ) + ) + + val encoded = Json.encodeToString(original) + val decoded = Json.decodeFromString(encoded) + + assertEquals("application/json; charset=utf-8", decoded["Content-Type"]) + assertEquals("Bearer token123", decoded["Authorization"]) + assertEquals("550e8400-e29b-41d4-a716-446655440000", decoded["X-Request-ID"]) + } + + // MARK: - Edge Cases + + @Test + fun `Headers handle empty string values`() { + val headers = EditorHTTPHeaders(mapOf("Empty-Header" to "")) + + assertEquals("", headers["Empty-Header"]) + } + + @Test + fun `Headers handle values with special characters`() { + val headers = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json; charset=utf-8", + "Link" to """; rel="next"""", + "Set-Cookie" to "session=abc123; Path=/; HttpOnly" + ) + ) + + assertEquals("application/json; charset=utf-8", headers["Content-Type"]) + assertEquals("""; rel="next"""", headers["Link"]) + assertEquals("session=abc123; Path=/; HttpOnly", headers["Set-Cookie"]) + } + + @Test + fun `Headers handle unicode values`() { + val headers = EditorHTTPHeaders( + mapOf( + "X-Greeting" to "こんにちは", + "X-Emoji" to "🎉" + ) + ) + + assertEquals("こんにちは", headers["X-Greeting"]) + assertEquals("🎉", headers["X-Emoji"]) + } + + @Test + fun `Headers handle very long values`() { + val longValue = "a".repeat(10000) + val headers = EditorHTTPHeaders(mapOf("X-Long-Header" to longValue)) + + assertEquals(longValue, headers["X-Long-Header"]) + } + + @Test + fun `Headers handle keys with hyphens`() { + val headers = EditorHTTPHeaders( + mapOf( + "Content-Type" to "text/html", + "X-Custom-Multi-Part-Header" to "value", + "Accept-Language" to "en-US" + ) + ) + + assertEquals("text/html", headers["Content-Type"]) + assertEquals("value", headers["X-Custom-Multi-Part-Header"]) + assertEquals("en-US", headers["Accept-Language"]) + } + + // MARK: - Filtering Tests + + @Test + fun `Filtering returns only matching headers`() { + val headers = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html", + "Link" to """; rel="next"""" + ) + ) + + val filtered = headers.filtering("Content-Type", "Link") + + assertEquals("application/json", filtered["Content-Type"]) + assertEquals("""; rel="next"""", filtered["Link"]) + assertNull(filtered["Accept"]) + } + + @Test + fun `Filtering with no matching keys returns empty headers`() { + val headers = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html" + ) + ) + + val filtered = headers.filtering("X-Custom-Header") + + assertNull(filtered["Content-Type"]) + assertNull(filtered["Accept"]) + assertNull(filtered["X-Custom-Header"]) + } + + @Test + fun `Filtering is case-insensitive`() { + val headers = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "Accept" to "text/html", + "Link" to "" + ) + ) + + // Filter with different case than stored keys + val filtered = headers.filtering("content-type", "LINK") + + assertEquals("application/json", filtered["Content-Type"]) + assertEquals("", filtered["Link"]) + assertNull(filtered["Accept"]) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorHttpMethodTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorHttpMethodTest.kt new file mode 100644 index 00000000..4e20a6de --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorHttpMethodTest.kt @@ -0,0 +1,31 @@ +package org.wordpress.gutenberg.model.http + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EditorHttpMethodTest { + + @Test + fun `Raw values are correct`() { + assertEquals("GET", EditorHttpMethod.GET.name) + assertEquals("POST", EditorHttpMethod.POST.name) + assertEquals("PUT", EditorHttpMethod.PUT.name) + assertEquals("DELETE", EditorHttpMethod.DELETE.name) + assertEquals("OPTIONS", EditorHttpMethod.OPTIONS.name) + } + + @Test + fun `All HTTP methods are defined`() { + val allMethods = EditorHttpMethod.entries + assertEquals( + listOf( + EditorHttpMethod.GET, + EditorHttpMethod.POST, + EditorHttpMethod.PUT, + EditorHttpMethod.DELETE, + EditorHttpMethod.OPTIONS + ), + allMethods + ) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorURLResponseTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorURLResponseTest.kt new file mode 100644 index 00000000..08c45073 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/http/EditorURLResponseTest.kt @@ -0,0 +1,81 @@ +package org.wordpress.gutenberg.model.http + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class EditorURLResponseTest { + + // MARK: - Initialization Tests + + @Test + fun `Initializes with data and headers`() { + val data = "test content" + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "text/plain")) + + val response = EditorURLResponse(data = data, responseHeaders = headers) + + assertEquals(data, response.data) + assertEquals(headers, response.responseHeaders) + } + + // MARK: - Equatable Tests + + @Test + fun `Equal responses are equal`() { + val data = "test" + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "text/plain")) + + val response1 = EditorURLResponse(data = data, responseHeaders = headers) + val response2 = EditorURLResponse(data = data, responseHeaders = headers) + + assertEquals(response1, response2) + } + + @Test + fun `Responses with different data are not equal`() { + val headers = EditorHTTPHeaders(mapOf("Content-Type" to "text/plain")) + + val response1 = EditorURLResponse(data = "test1", responseHeaders = headers) + val response2 = EditorURLResponse(data = "test2", responseHeaders = headers) + + assertNotEquals(response1, response2) + } + + @Test + fun `Responses with different headers are not equal`() { + val data = "test" + + val response1 = EditorURLResponse( + data = data, + responseHeaders = EditorHTTPHeaders(mapOf("Content-Type" to "text/plain")) + ) + val response2 = EditorURLResponse( + data = data, + responseHeaders = EditorHTTPHeaders(mapOf("Content-Type" to "application/json")) + ) + + assertNotEquals(response1, response2) + } + + // MARK: - Codable Tests + + @Test + fun `Response can be encoded and decoded`() { + val data = "test content" + val headers = EditorHTTPHeaders( + mapOf( + "Content-Type" to "application/json", + "X-Request-Id" to "123" + ) + ) + val original = EditorURLResponse(data = data, responseHeaders = headers) + + val encoded = Json.encodeToString(original) + val decoded = Json.decodeFromString(encoded) + + assertEquals(original, decoded) + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt new file mode 100644 index 00000000..fa24beed --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt @@ -0,0 +1,221 @@ +package org.wordpress.gutenberg.services + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.wordpress.gutenberg.EditorHTTPClientDownloadResponse +import org.wordpress.gutenberg.EditorHTTPClientProtocol +import org.wordpress.gutenberg.EditorHTTPClientResponse +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.http.EditorHTTPHeaders +import java.io.File +import java.util.concurrent.CopyOnWriteArrayList + +class EditorServiceTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + // Coroutine Scope for Tests + val testScope = TestScope() + + private lateinit var storageRoot: File + private lateinit var cacheRoot: File + + companion object { + private const val TEST_SITE_URL = "https://example.com" + private const val TEST_API_ROOT = "https://example.com/wp-json" + + val testConfiguration: EditorConfiguration = EditorConfiguration.builder( + TEST_SITE_URL, + TEST_API_ROOT, + "post" + ) + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test-token") + .build() + } + + @Before + fun setUp() { + storageRoot = tempFolder.newFolder("storage") + cacheRoot = tempFolder.newFolder("cache") + } + + private fun makeService( + configuration: EditorConfiguration = testConfiguration, + httpClient: EditorHTTPClientProtocol = EditorServiceMockHTTPClient() + ): EditorService { + return EditorService.createForTesting( + configuration = configuration, + httpClient = httpClient, + storageRoot = storageRoot, + cacheRoot = cacheRoot, + coroutineScope = testScope, + tempStorageRoot = tempFolder.newFolder("temp-storage") + ) + } + + // MARK: - fetchAssetBundleCount Tests + + @Test + fun `fetchAssetBundleCount returns zero when no bundles exist`() { + val service = makeService() + assertEquals(0, service.fetchAssetBundleCount()) + } + + // MARK: - DependencyWeights Tests + + @Test + fun `DependencyWeights have expected values`() { + assertEquals(10.0, EditorService.DependencyWeights.EDITOR_SETTINGS.weight, 0.001) + assertEquals(50.0, EditorService.DependencyWeights.ASSET_BUNDLE.weight, 0.001) + assertEquals(10.0, EditorService.DependencyWeights.POST.weight, 0.001) + assertEquals(10.0, EditorService.DependencyWeights.POST_TYPE.weight, 0.001) + assertEquals(10.0, EditorService.DependencyWeights.ACTIVE_THEME.weight, 0.001) + assertEquals(10.0, EditorService.DependencyWeights.SETTINGS_OPTIONS.weight, 0.001) + assertEquals(10.0, EditorService.DependencyWeights.POST_TYPES.weight, 0.001) + } + + @Test + fun `DependencyWeights sum to expected total`() { + val total = EditorService.DependencyWeights.entries.sumOf { it.weight } + // Total should be 110 (10+50+10+10+10+10+10) + assertEquals(110.0, total, 0.001) + } + + @Test + fun `DependencyWeights total companion property returns correct value`() { + assertEquals(110.0, EditorService.DependencyWeights.total, 0.001) + } + + // MARK: - cleanup and purge Tests + + @Test + fun `cleanup does not throw when no bundles exist`() { + val service = makeService() + service.cleanup() + service.cleanup() // Check that it can be called multiple times + } + + @Test + fun `purge completes without throwing for empty cache directory`() { + val service = makeService() + service.purge() + service.purge() // Check that it can be called multiple times + } + + // MARK: - prepare Tests with offline mode + + @Test + fun `prepare returns empty dependencies when offline mode is enabled`() = runBlocking { + val offlineConfiguration = testConfiguration.toBuilder() + .setEnableOfflineMode(true) + .build() + + val service = makeService(configuration = offlineConfiguration) + val dependencies = service.prepare() + + assertNotNull(dependencies) + assertEquals("undefined", dependencies.editorSettings.themeStyles) + } +} + +/** + * Mock HTTP client for EditorService tests. + */ +class EditorServiceMockHTTPClient : EditorHTTPClientProtocol { + + var getCallCount = 0 + private set + var downloadCallCount = 0 + private set + var downloadedURLs = CopyOnWriteArrayList() + private set + + private val lock = Any() + + // Default responses + private val emptyManifestJson = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": [] + } + """.trimIndent() + + private val emptyEditorSettingsJson = """ + { + "styles": [] + } + """.trimIndent() + + private val emptyPostTypeJson = """ + { + "name": "Posts", + "slug": "post" + } + """.trimIndent() + + private val emptyPostTypesJson = """ + { + "post": {"name": "Posts", "slug": "post"}, + "page": {"name": "Pages", "slug": "page"} + } + """.trimIndent() + + private val emptyThemeJson = """ + [{"name": "Twenty Twenty-Four"}] + """.trimIndent() + + private val emptySettingsJson = """ + {"title": "Test Site"} + """.trimIndent() + + override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse { + synchronized(lock) { + downloadCallCount++ + downloadedURLs.add(url) + } + + destination.parentFile?.mkdirs() + destination.writeText("mock content") + + return EditorHTTPClientDownloadResponse( + file = destination, + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } + + override suspend fun perform(method: String, url: String): EditorHTTPClientResponse { + synchronized(lock) { + if (method == "GET") { + getCallCount++ + } + } + + val responseData = when { + url.contains("editor-assets") -> emptyManifestJson + url.contains("wp-block-editor/v1/settings") -> emptyEditorSettingsJson + url.contains("/wp/v2/types/") && url.contains("context=edit") -> emptyPostTypeJson + url.contains("/wp/v2/types?context=view") -> emptyPostTypesJson + url.contains("/wp/v2/themes") -> emptyThemeJson + url.contains("/wp/v2/settings") -> emptySettingsJson + url.contains("/wp/v2/posts/") -> """{"id": 1, "title": {"rendered": "Test"}}""" + else -> "{}" + } + + return EditorHTTPClientResponse( + data = responseData.toByteArray(), + statusCode = 200, + headers = EditorHTTPHeaders() + ) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 810556eb..0a22ac63 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,9 @@ + \ 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 f93fce74..21a30c1d 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -10,10 +10,15 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Redo @@ -30,19 +35,32 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import org.wordpress.gutenberg.EditorConfiguration +import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView +import org.wordpress.gutenberg.EditorLoadingListener +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.model.EditorProgress class EditorActivity : ComponentActivity() { + + companion object { + const val EXTRA_DEPENDENCIES_PATH = "dependencies_path" + } + private var gutenbergView: GutenbergView? = null private lateinit var filePickerLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { @@ -75,12 +93,18 @@ class EditorActivity : ComponentActivity() { } else { @Suppress("DEPRECATION") intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION) - } ?: EditorConfiguration.builder().build() + } ?: EditorConfiguration.bundled() + + // Read dependencies from disk if a file path was provided + val dependenciesPath = intent.getStringExtra(EXTRA_DEPENDENCIES_PATH) + val dependencies = dependenciesPath?.let { EditorDependenciesSerializer.readFromDisk(it) } setContent { AppTheme { EditorScreen( configuration = configuration, + dependencies = dependencies, + coroutineScope = this.lifecycleScope, onClose = { finish() }, onGutenbergViewCreated = { view -> gutenbergView = view @@ -98,10 +122,26 @@ class EditorActivity : ComponentActivity() { } } +/** + * Loading state for the editor. + */ +enum class EditorLoadingState { + /** Dependencies are being loaded from the network */ + LOADING_DEPENDENCIES, + /** Dependencies loaded, waiting for WebView to initialize */ + LOADING_EDITOR, + /** Editor is fully ready */ + READY, + /** Loading failed with an error */ + ERROR +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, + dependencies: EditorDependencies? = null, + coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) { @@ -112,6 +152,16 @@ fun EditorScreen( var isCodeEditorEnabled by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } + // Loading state + var loadingState by remember { + mutableStateOf( + if (dependencies != null) EditorLoadingState.LOADING_EDITOR + else EditorLoadingState.LOADING_DEPENDENCIES + ) + } + var loadingProgress by remember { mutableFloatStateOf(0f) } + var loadingError by remember { mutableStateOf(null) } + BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() } @@ -208,7 +258,7 @@ fun EditorScreen( ) { innerPadding -> AndroidView( factory = { context -> - GutenbergView(context).apply { + GutenbergView(configuration, dependencies, coroutineScope, context).apply { gutenbergViewRef = this setModalDialogStateListener(object : GutenbergView.ModalDialogStateListener { override fun onModalDialogOpened(dialogType: String) { @@ -255,7 +305,29 @@ fun EditorScreen( } } }) - start(configuration) + setEditorLoadingListener(object : EditorLoadingListener { + override fun onDependencyLoadingStarted() { + loadingState = EditorLoadingState.LOADING_DEPENDENCIES + loadingProgress = 0f + } + + override fun onDependencyLoadingProgress(progress: EditorProgress) { + loadingProgress = progress.fractionCompleted.toFloat() + } + + override fun onDependencyLoadingFinished() { + loadingState = EditorLoadingState.LOADING_EDITOR + } + + override fun onEditorReady() { + loadingState = EditorLoadingState.READY + } + + override fun onDependencyLoadingFailed(error: Throwable) { + loadingState = EditorLoadingState.ERROR + loadingError = error.message ?: "Unknown error" + } + }) onGutenbergViewCreated(this) } }, @@ -263,5 +335,63 @@ fun EditorScreen( .fillMaxSize() .padding(innerPadding) ) + + // Loading overlay + when (loadingState) { + EditorLoadingState.LOADING_DEPENDENCIES -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + LinearProgressIndicator( + progress = { loadingProgress }, + modifier = Modifier.fillMaxWidth(0.6f) + ) + Text("Loading Editor...") + } + } + } + EditorLoadingState.LOADING_EDITOR -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text("Starting Editor...") + } + } + } + EditorLoadingState.ERROR -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Failed to load editor") + loadingError?.let { Text(it) } + } + } + } + EditorLoadingState.READY -> { + // Editor is ready, no overlay needed + } + } } } 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 69956b64..44554724 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -45,7 +46,8 @@ import com.example.gutenbergkit.ui.theme.AppTheme import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.wordpress.gutenberg.BuildConfig -import org.wordpress.gutenberg.EditorConfiguration +import org.wordpress.gutenberg.model.EditorConfiguration +import uniffi.wp_api.PostType class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCallback { private val configurations = mutableStateListOf() @@ -78,7 +80,7 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa configurations = configurations, onConfigurationClick = { config -> when (config) { - is ConfigurationItem.BundledEditor -> launchEditor(createBundledConfiguration()) + is ConfigurationItem.BundledEditor -> launchSitePreparation(config) is ConfigurationItem.ConfiguredEditor -> loadConfiguredEditor(config) } }, @@ -105,58 +107,36 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa } private fun createBundledConfiguration(): EditorConfiguration = - createCommonConfigurationBuilder() + createCommonConfigurationBuilder( + siteUrl = "https://example.com", + siteApiRoot = "https://example.com", + postType = "post" + ) .setPlugins(false) - .setSiteURL("") - .setSiteApiRoot("") .setSiteApiNamespace(arrayOf()) .setNamespaceExcludedPaths(arrayOf()) .setAuthHeader("") .setCookies(emptyMap()) + .setEnableOfflineMode(true) .build() private fun loadConfiguredEditor(config: ConfigurationItem.ConfiguredEditor) { - isLoadingCapabilities.value = true - - lifecycleScope.launch { - try { - val capabilities = siteCapabilitiesDiscovery.discoverCapabilities( - siteApiRoot = config.siteApiRoot - ) - - val editorConfiguration = createCommonConfigurationBuilder() - .setPlugins(capabilities.supportsPlugins) - .setThemeStyles(capabilities.supportsThemeStyles) - .setSiteURL(config.siteUrl) - .setSiteApiRoot(config.siteApiRoot) - .setNamespaceExcludedPaths(arrayOf()) - .setAuthHeader(config.authHeader) - .build() - - isLoadingCapabilities.value = false - launchEditor(editorConfiguration) - } catch (e: Exception) { - isLoadingCapabilities.value = false - // If capability discovery fails, use default configuration - val defaultConfiguration = createCommonConfigurationBuilder() - .setPlugins(false) - .setThemeStyles(false) - .setSiteURL(config.siteUrl) - .setSiteApiRoot(config.siteApiRoot) - .setNamespaceExcludedPaths(arrayOf()) - .setAuthHeader(config.authHeader) - .build() + launchSitePreparation(config) + } - launchEditor(defaultConfiguration) - } - } + private fun launchSitePreparation(config: ConfigurationItem) { + val intent = SitePreparationActivity.createIntent(this, config) + startActivity(intent) } - private fun createCommonConfigurationBuilder(): EditorConfiguration.Builder = - EditorConfiguration.builder() + private fun createCommonConfigurationBuilder(siteUrl: String, siteApiRoot: String, postType: String = "post"): EditorConfiguration.Builder = + EditorConfiguration.builder( + siteURL = siteUrl, + siteApiRoot = siteApiRoot, + postType = postType + ) .setTitle("") .setContent("") - .setPostType("post") .setThemeStyles(false) .setHideTitle(false) .setCookies(emptyMap()) @@ -343,7 +323,7 @@ fun MainScreen( } } -@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ConfigurationCard( configuration: ConfigurationItem, diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt new file mode 100644 index 00000000..b63fe140 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -0,0 +1,553 @@ +package com.example.gutenbergkit + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import org.json.JSONObject +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import com.example.gutenbergkit.ui.theme.AppTheme +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependenciesSerializer + +class SitePreparationActivity : ComponentActivity() { + + companion object { + private const val EXTRA_CONFIGURATION_ITEM = "configuration_item" + private const val KEY_TYPE = "type" + private const val KEY_NAME = "name" + private const val KEY_SITE_URL = "siteUrl" + private const val KEY_SITE_API_ROOT = "siteApiRoot" + private const val KEY_AUTH_HEADER = "authHeader" + private const val TYPE_BUNDLED = "bundled" + private const val TYPE_CONFIGURED = "configured" + + fun createIntent(context: Context, configurationItem: ConfigurationItem): Intent { + return Intent(context, SitePreparationActivity::class.java).apply { + putExtra(EXTRA_CONFIGURATION_ITEM, configurationItem.toJson()) + } + } + + private fun ConfigurationItem.toJson(): String { + return when (this) { + is ConfigurationItem.BundledEditor -> { + JSONObject().apply { + put(KEY_TYPE, TYPE_BUNDLED) + }.toString() + } + is ConfigurationItem.ConfiguredEditor -> { + JSONObject().apply { + put(KEY_TYPE, TYPE_CONFIGURED) + put(KEY_NAME, name) + put(KEY_SITE_URL, siteUrl) + put(KEY_SITE_API_ROOT, siteApiRoot) + put(KEY_AUTH_HEADER, authHeader) + }.toString() + } + } + } + + private fun parseConfigurationItem(extra: String?): ConfigurationItem { + if (extra == null) { + return ConfigurationItem.BundledEditor + } + + return try { + val json = JSONObject(extra) + when (json.optString(KEY_TYPE)) { + TYPE_CONFIGURED -> { + ConfigurationItem.ConfiguredEditor( + name = json.getString(KEY_NAME), + siteUrl = json.getString(KEY_SITE_URL), + siteApiRoot = json.getString(KEY_SITE_API_ROOT), + authHeader = json.getString(KEY_AUTH_HEADER) + ) + } + else -> ConfigurationItem.BundledEditor + } + } catch (e: Exception) { + ConfigurationItem.BundledEditor + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val configurationItem = parseConfigurationItem( + intent.getStringExtra(EXTRA_CONFIGURATION_ITEM) + ) + + val viewModel = ViewModelProvider( + this, + SitePreparationViewModelFactory(application, configurationItem) + )[SitePreparationViewModel::class.java] + + setContent { + AppTheme { + SitePreparationScreen( + viewModel = viewModel, + onClose = { finish() }, + onStartEditor = { configuration, dependencies -> + launchEditor(configuration, dependencies) + } + ) + } + } + } + + private fun launchEditor( + configuration: EditorConfiguration, + dependencies: org.wordpress.gutenberg.model.EditorDependencies? + ) { + val intent = Intent(this, EditorActivity::class.java).apply { + putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) + + // Serialize dependencies to disk and pass the file path + if (dependencies != null) { + val filePath = EditorDependenciesSerializer.writeToDisk(this@SitePreparationActivity, dependencies) + putExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH, filePath) + } + } + startActivity(intent) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SitePreparationScreen( + viewModel: SitePreparationViewModel, + onClose: () -> Unit, + onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.startLoading() + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Editor Configuration") }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + if (uiState.editorConfiguration != null) { + Button( + onClick = { + viewModel.buildConfiguration()?.let { config -> + onStartEditor(config, uiState.editorDependencies) + } + }, + modifier = Modifier.padding(end = 8.dp) + ) { + Text("Start") + } + } + } + ) + } + ) { innerPadding -> + if (uiState.editorConfiguration == null) { + LoadingView(modifier = Modifier.padding(innerPadding)) + } else { + LoadedView( + uiState = uiState, + viewModel = viewModel, + modifier = Modifier.padding(innerPadding) + ) + } + } +} + +@Composable +private fun LoadingView(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text("Loading Site Configuration") + } + } +} + +@Composable +private fun LoadedView( + uiState: SitePreparationUiState, + viewModel: SitePreparationViewModel, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Dependencies Status + item { + DependenciesStatusCard( + hasDependencies = uiState.editorDependencies != null + ) + } + + // Feature Configuration + item { + FeatureConfigurationCard( + enableNativeInserter = uiState.enableNativeInserter, + onEnableNativeInserterChange = viewModel::setEnableNativeInserter, + enableNetworkLogging = uiState.enableNetworkLogging, + onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, + postType = uiState.postType, + onPostTypeChange = viewModel::setPostType + ) + } + + // Editor Configuration Details + uiState.editorConfiguration?.let { config -> + item { + EditorConfigurationDetailsCard(configuration = config) + } + } + + // Local Caches + item { + LocalCachesCard( + isLoading = uiState.isLoading, + loadingProgress = uiState.loadingProgress, + cacheBundleCount = uiState.cacheBundleCount, + onPrepareEditor = viewModel::prepareEditor, + onPrepareEditorIgnoringCache = viewModel::prepareEditorFromScratch, + onClearCache = viewModel::resetEditorCaches + ) + } + + // Error + uiState.error?.let { error -> + item { + ErrorCard(error = error) + } + } + } +} + +@Composable +private fun DependenciesStatusCard(hasDependencies: Boolean) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (hasDependencies) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) + ) { + ListItem( + headlineContent = { + Text( + if (hasDependencies) { + "Editor Dependencies Loaded" + } else { + "Editor Dependencies Missing" + } + ) + }, + supportingContent = { + Text( + if (hasDependencies) { + "The editor should load instantly" + } else { + "The editor will need to load them when it starts" + } + ) + }, + leadingContent = { + Icon( + imageVector = if (hasDependencies) Icons.Default.CheckCircle else Icons.Default.Cancel, + contentDescription = null, + tint = if (hasDependencies) Color(0xFF4CAF50) else MaterialTheme.colorScheme.outline + ) + } + ) + } +} + +@Composable +private fun FeatureConfigurationCard( + enableNativeInserter: Boolean, + onEnableNativeInserterChange: (Boolean) -> Unit, + enableNetworkLogging: Boolean, + onEnableNetworkLoggingChange: (Boolean) -> Unit, + postType: String, + onPostTypeChange: (String) -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Feature Configuration", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Enable Native Inserter Toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Enable Native Inserter") + Switch( + checked = enableNativeInserter, + onCheckedChange = onEnableNativeInserterChange + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Enable Network Logging Toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Enable Network Logging") + Switch( + checked = enableNetworkLogging, + onCheckedChange = onEnableNetworkLoggingChange + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Post Type Picker + Text( + text = "Post Type", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + Column(modifier = Modifier.selectableGroup()) { + listOf("post" to "Post", "page" to "Page").forEach { (value, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = postType == value, + onClick = { onPostTypeChange(value) }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = postType == value, + onClick = null + ) + Text( + text = label, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + } + } +} + +@Composable +private fun EditorConfigurationDetailsCard(configuration: EditorConfiguration) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Editor Configuration Details", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + KeyValueRow(key = "Site URL", value = configuration.siteURL) + KeyValueRow(key = "API Root", value = configuration.siteApiRoot) + KeyValueBooleanRow(key = "Supports Block Assets", value = configuration.plugins) + KeyValueBooleanRow(key = "Supports Theme Styles", value = configuration.themeStyles) + } + } +} + +@Composable +private fun LocalCachesCard( + isLoading: Boolean, + loadingProgress: Float?, + cacheBundleCount: Int?, + onPrepareEditor: () -> Unit, + onPrepareEditorIgnoringCache: () -> Unit, + onClearCache: () -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Local Caches", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + cacheBundleCount?.let { count -> + Text( + text = "Asset bundles on disk: $count", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + Button( + onClick = onPrepareEditor, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("Prepare Editor") + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onPrepareEditorIgnoringCache, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("Prepare Editor Ignoring Cache") + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onClearCache, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("Clear Preload Cache") + } + + loadingProgress?.let { progress -> + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun ErrorCard(error: String) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Error", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Composable +private fun KeyValueRow(key: String, value: String) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = key, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun KeyValueBooleanRow(key: String, value: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = key) + Icon( + imageVector = if (value) Icons.Default.CheckCircle else Icons.Default.Cancel, + contentDescription = if (value) "Yes" else "No", + tint = if (value) Color(0xFF4CAF50) else Color(0xFFF44336), + modifier = Modifier.size(20.dp) + ) + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt new file mode 100644 index 00000000..eafbf82b --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -0,0 +1,235 @@ +package com.example.gutenbergkit + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wordpress.gutenberg.model.EditorCachePolicy +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.services.EditorService + +data class SitePreparationUiState( + val enableNativeInserter: Boolean = true, + val enableNetworkLogging: Boolean = false, + val postType: String = "post", + val cacheBundleCount: Int? = null, + val isLoading: Boolean = false, + val error: String? = null, + val editorConfiguration: EditorConfiguration? = null, + val editorDependencies: EditorDependencies? = null, + val loadingProgress: Float? = null +) + +class SitePreparationViewModel( + application: Application, + private val configurationItem: ConfigurationItem +) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(SitePreparationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val siteCapabilitiesDiscovery = SiteCapabilitiesDiscovery() + + fun startLoading() { + viewModelScope.launch { + try { + val configuration = when (configurationItem) { + is ConfigurationItem.BundledEditor -> createBundledConfiguration() + is ConfigurationItem.ConfiguredEditor -> loadConfiguration(configurationItem) + } + _uiState.update { it.copy(editorConfiguration = configuration) } + countAssetBundles() + } catch (e: Exception) { + _uiState.update { it.copy(error = e.message ?: "Unknown error") } + } + } + } + + fun setEnableNativeInserter(enabled: Boolean) { + _uiState.update { it.copy(enableNativeInserter = enabled) } + } + + fun setEnableNetworkLogging(enabled: Boolean) { + _uiState.update { it.copy(enableNetworkLogging = enabled) } + } + + fun setPostType(postType: String) { + _uiState.update { it.copy(postType = postType) } + } + + fun prepareEditor() { + val configuration = _uiState.value.editorConfiguration ?: return + + val cacheIntervalSeconds = 86_400L // Cache for one day + val editorService = EditorService.create( + context = getApplication(), + configuration = configuration, + cachePolicy = EditorCachePolicy.MaxAge(cacheIntervalSeconds), + coroutineScope = viewModelScope + ) + prepareEditor(editorService) + } + + fun prepareEditorFromScratch() { + val configuration = _uiState.value.editorConfiguration ?: return + + val editorService = EditorService.create( + context = getApplication(), + configuration = configuration, + cachePolicy = EditorCachePolicy.Ignore, + coroutineScope = viewModelScope + ) + prepareEditor(editorService) + } + + private fun prepareEditor(editorService: EditorService) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + try { + val dependencies = editorService.prepare { progress -> + _uiState.update { it.copy(loadingProgress = progress.fractionCompleted.toFloat()) } + } + + _uiState.update { + it.copy( + editorDependencies = dependencies, + isLoading = false, + loadingProgress = null + ) + } + + countAssetBundles() + } catch (e: Exception) { + _uiState.update { + it.copy( + error = e.message ?: "Unknown error", + isLoading = false, + loadingProgress = null + ) + } + } + } + } + + fun resetEditorCaches() { + val configuration = _uiState.value.editorConfiguration ?: return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + try { + _uiState.update { it.copy(editorDependencies = null) } + + val editorService = EditorService.create( + context = getApplication(), + configuration = configuration, + coroutineScope = viewModelScope + ) + editorService.purge() + + countAssetBundles() + + _uiState.update { it.copy(isLoading = false) } + } catch (e: Exception) { + _uiState.update { + it.copy( + error = e.message ?: "Unknown error", + isLoading = false + ) + } + } + } + } + + private fun countAssetBundles() { + viewModelScope.launch { + try { + val configuration = _uiState.value.editorConfiguration ?: run { + _uiState.update { it.copy(cacheBundleCount = 0) } + return@launch + } + + val editorService = EditorService.create( + context = getApplication(), + configuration = configuration, + coroutineScope = viewModelScope + ) + val count = editorService.fetchAssetBundleCount() + + _uiState.update { it.copy(cacheBundleCount = count) } + } catch (e: Exception) { + _uiState.update { it.copy(error = e.message ?: "Unknown error") } + } + } + } + + private fun createBundledConfiguration(): EditorConfiguration { + return EditorConfiguration.builder( + siteURL = "https://example.com", + siteApiRoot = "https://example.com", + postType = "post" + ) + .setPlugins(false) + .setSiteApiNamespace(arrayOf()) + .setNamespaceExcludedPaths(arrayOf()) + .setAuthHeader("") + .setCookies(emptyMap()) + .setEnableOfflineMode(true) + .build() + } + + private suspend fun loadConfiguration(config: ConfigurationItem.ConfiguredEditor): EditorConfiguration { + val capabilities = siteCapabilitiesDiscovery.discoverCapabilities( + siteApiRoot = config.siteApiRoot + ) + + return EditorConfiguration.builder( + siteURL = config.siteUrl, + siteApiRoot = config.siteApiRoot, + postType = _uiState.value.postType + ) + .setPlugins(capabilities.supportsPlugins) + .setThemeStyles(capabilities.supportsThemeStyles) + .setNamespaceExcludedPaths(arrayOf()) + .setAuthHeader(config.authHeader) + .setTitle("") + .setContent("") + .setHideTitle(false) + .setCookies(emptyMap()) + .setEnableNetworkLogging(true) + .setEnableAssetCaching(capabilities.supportsPlugins) + .build() + } + + fun buildConfiguration(): EditorConfiguration? { + val baseConfig = _uiState.value.editorConfiguration ?: return null + + return baseConfig.toBuilder() + .setEnableNetworkLogging(_uiState.value.enableNetworkLogging) + // TODO: Add setNativeInserterEnabled when it's available in EditorConfiguration + .setPostType(_uiState.value.postType) + .build() + } +} + +class SitePreparationViewModelFactory( + private val application: Application, + private val configurationItem: ConfigurationItem +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SitePreparationViewModel::class.java)) { + return SitePreparationViewModel(application, configurationItem) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 3b1878a7..56926386 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -12,5 +12,6 @@ ext { plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.jetbrains.kotlin.serialization) apply false alias(libs.plugins.android.library) apply false } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index bbec83f7..3f0d56c5 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.7.3" kotlin = "2.0.21" +kotlinx-serialization = "1.7.3" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.2.1" @@ -18,6 +19,8 @@ androidx-recyclerview = '1.3.2' wordpress-rs = 'trunk-d02efa6d4d56bc5b44dd2191e837163f9fa27095' composeBom = "2024.12.01" activityCompose = "1.9.3" +jsoup = "1.18.1" +okhttp = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -34,6 +37,8 @@ mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mo mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview" } wordpress-rs-android = { group = "rs.wordpress.api", name = "android", version.ref = "wordpress-rs" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -42,9 +47,13 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift index b4d36d84..e006fb9c 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift @@ -101,10 +101,17 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable { /// /// - Parameter url: The original remote URL of the asset. /// - Returns: The local file URL where the asset is stored. - /// - Throws: `Errors.invalidRequest` if the asset is not in this bundle. + /// - Precondition: The URL path must not escape the bundle root directory. public func assetDataPath(for url: URL) -> URL { let path = url.path(percentEncoded: false) - let bundlePath = self.bundleRoot.appending(rawPath: path) + let bundlePath = self.bundleRoot.appending(rawPath: path).standardizedFileURL + let bundleRootPath = self.bundleRoot.standardizedFileURL.path + + precondition( + bundlePath.path.hasPrefix(bundleRootPath + "/") || bundlePath.path == bundleRootPath, + "Asset path escapes bundle root: \(path)" + ) + return bundlePath } diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorSettings.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorSettings.swift index 5aa34559..b62eeba4 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorSettings.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorSettings.swift @@ -25,18 +25,15 @@ public struct EditorSettings: Sendable, Codable, Equatable, Hashable { /// be cached. /// /// - Parameter data: The raw JSON data from the block editor settings endpoint. - init(data: Data) { - let json = try? JSON(data) + init(data: Data) throws { + let json = try JSON(data) self.jsonValue = json - let encodedJSON = try? JSONEncoder().encode(json) - self.stringValue = String(decoding: encodedJSON ?? Data(), as: UTF8.self) + let encodedJSON = try JSONEncoder().encode(json) + self.stringValue = String(decoding: encodedJSON, as: UTF8.self) - if let settings = try? JSONDecoder().decode(InternalEditorSettings.self, from: data) { - self.themeStyles = settings.styles.compactMap { $0.css }.joined(separator: "\n") - } else { - self.themeStyles = "" - } + let settings = try JSONDecoder().decode(InternalEditorSettings.self, from: data) + self.themeStyles = settings.styles?.compactMap { $0.css }.joined(separator: "\n") ?? "" } private init( @@ -67,5 +64,5 @@ struct InternalEditorSettings: Decodable { } /// All style entries from the theme. - let styles: [CSSStyle] + let styles: [CSSStyle]? } diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index a65df4d1..7db332f9 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -72,7 +72,7 @@ public struct RESTAPIRepository: Sendable { let request = URLRequest(method: .GET, url: editorSettingsUrl) let response = try await self.httpClient.perform(request) - let editorSettings = EditorSettings(data: response.0) + let editorSettings = try EditorSettings(data: response.0) let urlResponse = EditorURLResponse((try JSONEncoder().encode(editorSettings), response.1)) try self.cache.store(urlResponse, for: editorSettingsUrl, httpMethod: .GET) diff --git a/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift index c383baff..e777a5a3 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift @@ -270,6 +270,43 @@ struct EditorAssetBundleTests { #expect(result.path.contains("/wp-content/plugins/script.js")) } + @Test("assetDataPath allows valid nested paths") + func assetDataPathAllowsValidNestedPaths() { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = makeBundle(bundleRoot: tempDir) + + let url = URL(string: "https://example.com/wp-content/plugins/my-plugin/assets/js/script.js")! + let result = bundle.assetDataPath(for: url) + + #expect(result.standardizedFileURL.path.hasPrefix(tempDir.standardizedFileURL.path)) + } + + @Test("assetDataPath normalizes paths with dot segments") + func assetDataPathNormalizesDotsSegments() { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = makeBundle(bundleRoot: tempDir) + + // This path has ./ which should be normalized but stay within bundle + let url = URL(string: "https://example.com/wp-content/./plugins/script.js")! + let result = bundle.assetDataPath(for: url) + + #expect(result.standardizedFileURL.path.hasPrefix(tempDir.standardizedFileURL.path)) + #expect(result.path.contains("plugins/script.js")) + } + + @Test("assetDataPath crashes for path traversal attempt") + func assetDataPathCrashesForPathTraversal() async { + await #expect(processExitsWith: .failure) { + let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let bundle = EditorAssetBundle( + raw: EditorAssetBundle.RawAssetBundle(manifest: .empty, downloadDate: Date()), + bundleRoot: tempDir + ) + let url = URL(string: "https://example.com/../../../etc/passwd")! + _ = bundle.assetDataPath(for: url) + } + } + // MARK: - assetData Tests @Test("assetData returns data for existing file") diff --git a/ios/Tests/GutenbergKitTests/Model/EditorSettingsTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorSettingsTests.swift index 04f94908..b8f67c4a 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorSettingsTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorSettingsTests.swift @@ -6,123 +6,125 @@ import Testing @Suite struct EditorSettingsTests { - // MARK: - themeStyles Tests - - @Test("themeStyles is empty when styles array is empty") - func themeStylesEmptyWhenNoStyles() { - let json = #"{"styles": []}"# - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles.isEmpty) - } - - @Test("themeStyles extracts single css value") - func themeStylesExtractsSingleCSS() { - let json = #"{"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}]}"# - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles == "body { color: red; }") - } - - @Test("themeStyles joins multiple css values with newlines") - func themeStylesJoinsMultipleCSS() { - let json = """ + @Test("Throws for invalid JSON") + func forInvalidJSON() throws{ + let invalidJSON = "not valid json" + + #expect(throws: DecodingError.self) { + try EditorSettings(data: Data(invalidJSON.utf8)) + } + } + + // MARK: - themeStyles Tests + + @Test("themeStyles is empty when styles array is empty") + func themeStylesEmptyWhenNoStyles() throws { + let json = #"{"styles": []}"# + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles.isEmpty) + } + + @Test("themeStyles extracts single css value") + func themeStylesExtractsSingleCSS() throws{ + let json = #"{"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}]}"# + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles == "body { color: red; }") + } + + @Test("themeStyles joins multiple css values with newlines") + func themeStylesJoinsMultipleCSS() throws{ + let json = """ {"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}, {"css": "h1 { font-size: 2em; }", "isGlobalStyles": false}]} """ - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles == "body { color: red; }\nh1 { font-size: 2em; }") - } + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles == "body { color: red; }\nh1 { font-size: 2em; }") + } - @Test("themeStyles skips styles with null css") - func themeStylesSkipsNullCSS() { - let json = """ + @Test("themeStyles skips styles with null css") + func themeStylesSkipsNullCSS() throws{ + let json = """ {"styles": [{"css": null, "isGlobalStyles": true}, {"css": "h1 { font-size: 2em; }", "isGlobalStyles": false}]} """ - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles == "h1 { font-size: 2em; }") - } + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles == "h1 { font-size: 2em; }") + } - @Test("themeStyles skips styles without css key") - func themeStylesSkipsMissingCSS() { - let json = """ + @Test("themeStyles skips styles without css key") + func themeStylesSkipsMissingCSS() throws{ + let json = """ {"styles": [{"isGlobalStyles": true}, {"css": "h1 { font-size: 2em; }", "isGlobalStyles": false}]} """ - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles == "h1 { font-size: 2em; }") - } - - @Test("themeStyles is empty when JSON is invalid") - func themeStylesEmptyForInvalidJSON() { - let invalidJSON = "not valid json" - let settings = EditorSettings(data: Data(invalidJSON.utf8)) - #expect(settings.themeStyles.isEmpty) - } - - @Test("themeStyles is empty when styles key is missing") - func themeStylesEmptyWhenStylesKeyMissing() { - let json = """ + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles == "h1 { font-size: 2em; }") + } + + @Test("themeStyles is empty when styles key is missing") + func themeStylesEmptyWhenStylesKeyMissing() throws{ + let json = """ {"otherKey": "value"} """ - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles.isEmpty) - } + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles.isEmpty) + } - // MARK: - Codable Tests + // MARK: - Codable Tests - @Test("EditorSettings can be encoded and decoded") - func encodableAndDecodable() throws { - let json = """ + @Test("EditorSettings can be encoded and decoded") + func encodableAndDecodable() throws { + let json = """ {"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}]} """ - let original = EditorSettings(data: Data(json.utf8)) + let original = try EditorSettings(data: Data(json.utf8)) - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(EditorSettings.self, from: encoded) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(EditorSettings.self, from: encoded) - #expect(decoded.stringValue == original.stringValue) - #expect(decoded.themeStyles == original.themeStyles) - } + #expect(decoded.stringValue == original.stringValue) + #expect(decoded.themeStyles == original.themeStyles) + } - @Test("EditorSettings preserves themeStyles through encoding round-trip") - func themeStylesPreservedThroughRoundTrip() throws { - let json = """ + @Test("EditorSettings preserves themeStyles through encoding round-trip") + func themeStylesPreservedThroughRoundTrip() throws { + let json = """ {"styles": [{"css": ".theme-class { background: blue; }", "isGlobalStyles": true}, {"css": ".another { margin: 10px; }", "isGlobalStyles": false}]} """ - let original = EditorSettings(data: Data(json.utf8)) + let original = try EditorSettings(data: Data(json.utf8)) - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(EditorSettings.self, from: encoded) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(EditorSettings.self, from: encoded) - #expect(decoded.themeStyles == ".theme-class { background: blue; }\n.another { margin: 10px; }") - } + #expect(decoded.themeStyles == ".theme-class { background: blue; }\n.another { margin: 10px; }") + } - // MARK: - Edge Cases + // MARK: - Edge Cases - @Test("themeStyles handles empty css string") - func themeStylesHandlesEmptyCSS() { - let json = """ + @Test("themeStyles handles empty css string") + func themeStylesHandlesEmptyCSS() throws { + let json = """ {"styles": [{"css": "", "isGlobalStyles": true}]} """ - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles == "") - } + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles == "") + } - @Test("themeStyles handles css with special characters") - func themeStylesHandlesSpecialCharacters() { - let json = """ + @Test("themeStyles handles css with special characters") + func themeStylesHandlesSpecialCharacters() throws { + let json = """ {"styles": [{"css": ".class::before { content: '\\u003C'; }", "isGlobalStyles": true}]} """ - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles.contains("::before")) - } + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles.contains("::before")) + } - @Test("themeStyles handles multiline css") - func themeStylesHandlesMultilineCSS() { - let json = """ + @Test("themeStyles handles multiline css") + func themeStylesHandlesMultilineCSS() throws { + let json = """ {"styles": [{"css": "body {\\n color: red;\\n background: blue;\\n}", "isGlobalStyles": true}]} """ - let settings = EditorSettings(data: Data(json.utf8)) - #expect(settings.themeStyles.contains("color: red")) - #expect(settings.themeStyles.contains("background: blue")) - } + let settings = try EditorSettings(data: Data(json.utf8)) + #expect(settings.themeStyles.contains("color: red")) + #expect(settings.themeStyles.contains("background: blue")) + } } // MARK: - InternalEditorSettings Tests @@ -130,63 +132,63 @@ struct EditorSettingsTests { @Suite struct InternalEditorSettingsTests { - @Test("decodes styles array correctly") - func decodesStylesArray() throws { - let json = """ + @Test("decodes styles array correctly") + func decodesStylesArray() throws { + let json = """ {"styles": [{"css": "body { color: red; }", "isGlobalStyles": true}]} """ - let settings = try JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)) - #expect(settings.styles.count == 1) - #expect(settings.styles[0].css == "body { color: red; }") - #expect(settings.styles[0].isGlobalStyles == true) - } - - @Test("decodes multiple styles") - func decodesMultipleStyles() throws { - let json = """ + let styles = try #require(JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)).styles) + #expect(styles.count == 1) + #expect(styles[0].css == "body { color: red; }") + #expect(styles[0].isGlobalStyles == true) + } + + @Test("decodes multiple styles") + func decodesMultipleStyles() throws { + let json = """ {"styles": [{"css": "a", "isGlobalStyles": true}, {"css": "b", "isGlobalStyles": false}]} """ - let settings = try JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)) - #expect(settings.styles.count == 2) - #expect(settings.styles[0].css == "a") - #expect(settings.styles[1].css == "b") - #expect(settings.styles[0].isGlobalStyles == true) - #expect(settings.styles[1].isGlobalStyles == false) - } - - @Test("decodes null css value") - func decodesNullCSS() throws { - let json = """ + let styles = try #require(JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)).styles) + #expect(styles.count == 2) + #expect(styles[0].css == "a") + #expect(styles[1].css == "b") + #expect(styles[0].isGlobalStyles == true) + #expect(styles[1].isGlobalStyles == false) + } + + @Test("decodes null css value") + func decodesNullCSS() throws { + let json = """ {"styles": [{"css": null, "isGlobalStyles": true}]} """ - let settings = try JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)) - #expect(settings.styles.count == 1) - #expect(settings.styles[0].css == nil) - } - - @Test("decodes empty styles array") - func decodesEmptyStylesArray() throws { - let json = """ + let styles = try #require(JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)).styles) + #expect(styles.count == 1) + #expect(styles[0].css == nil) + } + + @Test("decodes empty styles array") + func decodesEmptyStylesArray() throws { + let json = """ {"styles": []} """ - let settings = try JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)) - #expect(settings.styles.isEmpty) - } - - @Test("parses real editor settings test case") - func parsesRealEditorSettingsTestCase() throws { - let data = try Data.forResource(named: "editor-settings-test-case-1") - let settings = try JSONDecoder().decode(InternalEditorSettings.self, from: data) - - // The test file should have multiple styles - #expect(!settings.styles.isEmpty) - - // At least one style should have CSS content - let stylesWithCSS = settings.styles.filter { $0.css != nil && !$0.css!.isEmpty } - #expect(!stylesWithCSS.isEmpty) - - // Verify isGlobalStyles is parsed - let globalStyles = settings.styles.filter { $0.isGlobalStyles } - #expect(!globalStyles.isEmpty) - } + let styles = try #require(JSONDecoder().decode(InternalEditorSettings.self, from: Data(json.utf8)).styles) + #expect(styles.isEmpty) + } + + @Test("parses real editor settings test case") + func parsesRealEditorSettingsTestCase() throws { + let data = try Data.forResource(named: "editor-settings-test-case-1") + let styles = try #require(JSONDecoder().decode(InternalEditorSettings.self, from: data).styles) + + // The test file should have multiple styles + #expect(!styles.isEmpty) + + // At least one style should have CSS content + let stylesWithCSS = styles.filter { $0.css != nil && !$0.css!.isEmpty } + #expect(!stylesWithCSS.isEmpty) + + // Verify isGlobalStyles is parsed + let globalStyles = styles.filter { $0.isGlobalStyles } + #expect(!globalStyles.isEmpty) + } }