Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ class GutenbergView : FrameLayout {
private var hasAutofocused = false
private lateinit var assetLoader: WebViewAssetLoader
private lateinit var assetAuthority: String

/** The editor document [loadEditor] loaded, so its load failures can be told apart. */
private var editorUri: Uri? = null
private val configuration: EditorConfiguration
private lateinit var dependencies: EditorDependencies

Expand Down Expand Up @@ -443,8 +446,30 @@ class GutenbergView : FrameLayout {
request: WebResourceRequest?,
error: WebResourceError?
) {
Log.e("GutenbergView", "Received web error: $error")
val url = request?.url
val description = error?.description?.toString()
Log.e("GutenbergView", "Received web error ${error?.errorCode} ($description) for $url")
super.onReceivedError(view, request, error)

if (request?.isForMainFrame == true && url != null && isEditorDocument(url)) {
showEditorLoadErrorPhase(url, description)
}
}

override fun onReceivedHttpError(
view: WebView?,
request: WebResourceRequest?,
errorResponse: WebResourceResponse?
) {
super.onReceivedHttpError(view, request, errorResponse)

// A missing bundled `index.html` also lands here, as a 404 from the asset loader.
val url = request?.url
if (request?.isForMainFrame == true && url != null && isEditorDocument(url)) {
val status = "HTTP ${errorResponse?.statusCode}"
Log.e("GutenbergView", "Received $status for the editor document $url")
showEditorLoadErrorPhase(url, status)
}
}

override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
Expand Down Expand Up @@ -576,6 +601,23 @@ class GutenbergView : FrameLayout {
}
}

/** Whether [url] is the editor document, ignoring an explicit default port or empty path. */
private fun isEditorDocument(url: Uri): Boolean {
val editorUri = editorUri ?: return false
return url.scheme == editorUri.scheme &&
originAuthority(url.toString()) == originAuthority(editorUri.toString()) &&
url.path.orEmpty().ifEmpty { "/" } == editorUri.path.orEmpty().ifEmpty { "/" }
}

/**
* Shows why the editor document at [url] failed to load. Otherwise the editor
* never signals readiness and the spinner never ends.
*/
private fun showEditorLoadErrorPhase(url: Uri, reason: String?) {
val isDevServer = BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()
showErrorPhase(Exception(editorLoadErrorMessage(url, reason, isDevServer)))
}

/**
* Fetches all required dependencies and then loads the editor.
*
Expand Down Expand Up @@ -657,6 +699,7 @@ class GutenbergView : FrameLayout {
val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty {
assetUrl
}
editorUri = Uri.parse(editorUrl)

WebStorage.getInstance().deleteAllData()
webView.clearCache(true)
Expand Down Expand Up @@ -1410,6 +1453,52 @@ class GutenbergView : FrameLayout {
return url.authority == devServerAuthority
}

/**
* Why the editor document at [url] failed to load, with a hint keyed to
* [description] (Chromium's `net::ERR_CONNECTION_REFUSED`, or `HTTP 404`)
* when it is a dev server. Null for the bundled editor, so the view shows
* its localized text.
*/
internal fun editorLoadErrorMessage(url: Uri, description: String?, isDevServer: Boolean): String? {
if (!isDevServer) return null
val reason = description.orEmpty()
val hint = when {
"ERR_CONNECTION_REFUSED" in reason && url.host in DEVICE_LOOPBACK_HOSTS && url.port != -1 ->
"Is the dev server running? Start it with \"make serve-dev\". " +
"On a physical device, also run \"adb reverse tcp:${url.port} tcp:${url.port}\"."
"ERR_CONNECTION_REFUSED" in reason ->
"Is the dev server running? Start it with \"make serve-dev\"."
"ERR_CLEARTEXT_NOT_PERMITTED" in reason ->
"Allow cleartext traffic to ${url.host} in the app's network security config."
UNREACHABLE_HOST_ERRORS.any { it in reason } && url.host == EMULATOR_HOST_ALIAS ->
"$EMULATOR_HOST_ALIAS reaches your computer only from the emulator. " +
"On a physical device, use your computer's LAN IP address instead."
UNREACHABLE_HOST_ERRORS.any { it in reason } ->
"Check that this device can reach ${url.host}: its IP address, network, and firewall."
reason.startsWith("HTTP ") ->
"Check that GUTENBERG_EDITOR_URL in local.properties points at the dev server."
else -> null
}
val summary = "Couldn't load the editor from $url (${description ?: "unknown error"})."
return listOfNotNull(summary, hint).joinToString(" ")
}

/** Hosts that resolve to the device itself, which reaches the dev machine only through `adb reverse`. */
private val DEVICE_LOOPBACK_HOSTS = setOf("localhost", "127.0.0.1")

/** The emulator's alias for the dev machine; a physical device can't reach it. */
private const val EMULATOR_HOST_ALIAS = "10.0.2.2"

private val UNREACHABLE_HOST_ERRORS = listOf(
"ERR_CONNECTION_TIMED_OUT",
"ERR_CONNECTION_FAILED",
"ERR_TIMED_OUT",
"ERR_ADDRESS_UNREACHABLE",
"ERR_NAME_NOT_RESOLVED",
"ERR_INTERNET_DISCONNECTED",
"ERR_NETWORK_CHANGED"
)

private const val ASSET_LOADING_TIMEOUT_MS = 5000L

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package org.wordpress.gutenberg

import android.net.Uri
import android.os.Looper
import android.view.View
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import kotlinx.coroutines.test.TestScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.wordpress.gutenberg.model.EditorConfiguration
import org.wordpress.gutenberg.model.EditorDependencies

/** How a failure to load the editor document reaches the user instead of an endless spinner. */
@RunWith(RobolectricTestRunner::class)
class GutenbergViewLoadErrorTest {

private val testScope = TestScope()

private val devServer = Uri.parse("http://10.0.2.2:5173/")

private val lanDevServer = Uri.parse("http://192.168.1.5:5173/")

private fun siteView() = GutenbergView(
EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/").build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

/** The editor document, mirroring the fallback in `loadEditor`. */
private val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL
.ifEmpty { "https://example.com/assets/index.html" }

private fun failMainFrameLoad(view: GutenbergView, url: String) {
val error = mock(WebResourceError::class.java)
`when`(error.description).thenReturn("net::ERR_CONNECTION_REFUSED")
view.editorWebView.webViewClient.onReceivedError(view.editorWebView, request(url, isForMainFrame = true), error)
shadowOf(Looper.getMainLooper()).idle()
}

private fun respondWithNotFound(view: GutenbergView, url: String, isForMainFrame: Boolean) {
val response = WebResourceResponse(null, null, 404, "Not Found", null, null)
view.editorWebView.webViewClient.onReceivedHttpError(view.editorWebView, request(url, isForMainFrame), response)
shadowOf(Looper.getMainLooper()).idle()
}

private fun request(url: String, isForMainFrame: Boolean): WebResourceRequest {
val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(Uri.parse(url))
`when`(request.isForMainFrame).thenReturn(isForMainFrame)
return request
}

@Test
fun `a failed editor document load replaces the spinner with the error view`() {
val view = siteView()
shadowOf(Looper.getMainLooper()).idle()

failMainFrameLoad(view, editorUrl)

assertEquals(
"the failed editor must give way to the error view",
View.INVISIBLE,
view.editorWebView.visibility
)
}

@Test
fun `a failed load of another page leaves the editor as it is`() {
val view = siteView()
shadowOf(Looper.getMainLooper()).idle()

failMainFrameLoad(view, "https://example.com/wp-json/wp/v2/posts")

assertEquals(View.VISIBLE, view.editorWebView.visibility)
}

@Test
fun `an HTTP error for the editor document replaces the spinner with the error view`() {
val view = siteView()
shadowOf(Looper.getMainLooper()).idle()

respondWithNotFound(view, editorUrl, isForMainFrame = true)

assertEquals(
"the missing editor must give way to the error view",
View.INVISIBLE,
view.editorWebView.visibility
)
}

@Test
fun `an HTTP error for a subresource leaves the editor as it is`() {
val view = siteView()
shadowOf(Looper.getMainLooper()).idle()

respondWithNotFound(view, "https://example.com/wp-content/uploads/missing.jpg", isForMainFrame = false)

assertEquals(View.VISIBLE, view.editorWebView.visibility)
}

@Test
fun `editorLoadErrorMessage suggests checking the editor URL for an HTTP error`() {
val message = GutenbergView.editorLoadErrorMessage(devServer, "HTTP 404", true)

assertTrue(message.orEmpty().contains("GUTENBERG_EDITOR_URL"))
}

@Test
fun `editorLoadErrorMessage suggests starting a refused dev server`() {
val message = GutenbergView.editorLoadErrorMessage(devServer, "net::ERR_CONNECTION_REFUSED", true)

assertTrue(message.orEmpty().contains("make serve-dev"))
assertFalse(message.orEmpty().contains("adb reverse"))
}

@Test
fun `editorLoadErrorMessage suggests forwarding the port for a refused loopback dev server`() {
val message = GutenbergView.editorLoadErrorMessage(
Uri.parse("http://localhost:5173/"),
"net::ERR_CONNECTION_REFUSED",
true
)

assertTrue(message.orEmpty().contains("make serve-dev"))
assertTrue(message.orEmpty().contains("adb reverse tcp:5173 tcp:5173"))
}

@Test
fun `editorLoadErrorMessage suggests allowing cleartext to the dev server's host`() {
val message = GutenbergView.editorLoadErrorMessage(devServer, "net::ERR_CLEARTEXT_NOT_PERMITTED", true)

assertTrue(message.orEmpty().contains("cleartext traffic to 10.0.2.2"))
}

@Test
fun `editorLoadErrorMessage suggests checking reachability of an unreachable host`() {
val message = GutenbergView.editorLoadErrorMessage(lanDevServer, "net::ERR_CONNECTION_TIMED_OUT", true)

assertTrue(message.orEmpty().contains("can reach 192.168.1.5"))
}

@Test
fun `editorLoadErrorMessage suggests checking reachability of a disconnected device`() {
val message = GutenbergView.editorLoadErrorMessage(lanDevServer, "net::ERR_INTERNET_DISCONNECTED", true)

assertTrue(message.orEmpty().contains("can reach 192.168.1.5"))
}

@Test
fun `editorLoadErrorMessage suggests a LAN IP address for an unreachable emulator alias`() {
val message = GutenbergView.editorLoadErrorMessage(devServer, "net::ERR_CONNECTION_TIMED_OUT", true)

assertTrue(message.orEmpty().contains("only from the emulator"))
assertTrue(message.orEmpty().contains("LAN IP address"))
}

@Test
fun `editorLoadErrorMessage leaves the bundled editor to the localized message`() {
val bundledEditor = Uri.parse("https://example.com/assets/index.html")

assertNull(GutenbergView.editorLoadErrorMessage(bundledEditor, "net::ERR_FAILED", false))
assertNull(GutenbergView.editorLoadErrorMessage(bundledEditor, "HTTP 404", false))
}
}
Loading