Skip to content

Commit

Permalink
Merge pull request android#177 from android/webview-testing
Browse files Browse the repository at this point in the history
Webview testing
  • Loading branch information
nic0lette authored Sep 1, 2020
2 parents 44c6611 + 0d001ba commit 8a9927f
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 103 deletions.
11 changes: 10 additions & 1 deletion WebView/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ dependencies {
implementation "com.google.android.material:material:$material_components_version"

testImplementation 'junit:junit:4.13'
testImplementation 'androidx.test:core:1.0.0'
testImplementation 'org.mockito:mockito-core:1.10.19'

androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
androidTestImplementation 'androidx.test.espresso:espresso-web:3.1.0'
androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-intents:3.0.2'
androidTestImplementation 'androidx.test:rules:1.1.1'
androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0"
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.android.samples.webviewdemo

import android.content.Context
import android.os.Looper
import android.webkit.WebView
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.model.Atoms.castOrDie
import androidx.test.espresso.web.model.Atoms.script
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.*
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.rule.ActivityTestRule
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.CoreMatchers.containsString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Launch, interact, and verify conditions in an activity that has a WebView instance.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class MainActivityTest {

private val context = ApplicationProvider.getApplicationContext<Context>()

@Rule
@JvmField
val mainActivityRule = ActivityTestRule(MainActivity::class.java)

// Test to check that the drop down menu behaves as expected
@Test
fun dropDownMenu_SanFran() {
onWebView()
.forceJavascriptEnabled()
.withElement(findElement(Locator.ID, "location"))
.perform(webClick()) // Similar to perform(click())
.withElement(findElement(Locator.ID, "SF"))
.perform(webClick()) // Similar to perform(click())
.withElement(findElement(Locator.ID, "title"))
.check(webMatches(getText(), containsString("San Francisco")))
}

// Test for checking createJsObject
@Test
fun jsObjectIsInjectedAndContainsPostMessage() {
onWebView()
.check(
webMatches(
script(
"return jsObject && typeof jsObject.postMessage == \"function\"",
castOrDie(Boolean::class.javaObjectType)
),
`is`(true)
)
)
}

@Test
fun valueInCallback_compareValueInput() = runBlocking {
// Setup
val jsObjName = "jsObject"
val allowedOriginRules = setOf("https://example.com")
val expectedMessage = "hello"
val onMessageReceived = CompletableDeferred<String?>()
launch(Dispatchers.Main) {
val webView = WebView(context).apply {
settings.javaScriptEnabled = true
}
// Create & inject JsObject
createJsObject(
webView,
jsObjName,
allowedOriginRules
) { message ->
onMessageReceived.complete(message)
}
webView.loadDataWithBaseURL(
"https://example.com",
"<html><script>${jsObjName}.postMessage('${expectedMessage}')</script></html>",
"text/html",
null,
null
)
}
// evaluate argument being passed into onMessageReceived
assertEquals(expectedMessage, onMessageReceived.await())
}

@Test
fun checkingThreadCallbackRunsOn() = runBlocking {
// Setup
val jsObjName = "jsObject"
val allowedOriginRules = setOf("https://example.com")
val expectedMessage = "hello"
val onMessageReceived = CompletableDeferred<Looper?>()
launch(Dispatchers.Main) {
val webView = WebView(context).apply {
settings.javaScriptEnabled = true
}
// Create & inject JsObject
createJsObject(
webView,
jsObjName,
allowedOriginRules
) {
onMessageReceived.complete(Looper.myLooper())
}
webView.loadDataWithBaseURL(
"https://example.com",
"<html><script>${jsObjName}.postMessage('${expectedMessage}')</script></html>",
"text/html",
null,
null
)
}
assertEquals(onMessageReceived.await(), Looper.getMainLooper())
}
}
6 changes: 3 additions & 3 deletions WebView/app/src/main/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
<body onload="getData()">
<label for="location">Choose a location:</label>
<select name="location" id="location" onchange="getData()">
<option value="newYork">New York</option>
<option value="sanFrancisco">San Francisco</option>
<option value="london">London</option>
<option id= "NYC" value="newYork">New York</option>
<option id= "SF" value="sanFrancisco">San Francisco</option>
<option id= "LDN" value="london">London</option>
</select>
<h1 id="title">Location</h1>
<img alt="weather" class="icon" id="icon" src="https://raw.githubusercontent.com/views-widgets-samples/res/drawable/sunny.png" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.android.samples.webviewdemo

import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature

// Create a handler that runs on the UI thread
private val handler: Handler = Handler(Looper.getMainLooper())

/**
* Injects a JavaScript object which supports a {@code postMessage()} method.
* A feature check is used to determine if the preferred API, WebMessageListener, is supported.
* If it is, then WebMessageListener will be used to create a JavaScript object. The object will be
* injected into all of the frames that have an origin matching those in {@code allowedOriginRules}.
* <p>
* If WebMessageListener is not supported then the method will defer to using JavascriptInterface
* to create the JavaScript object.
* <p>
* The {@code postMessage()} methods in the Javascript objects created by WebMessageListener and
* JavascriptInterface both make calls to the same callback, {@code onMessageReceived()}.
* In this case, the callback invokes native Android sharing.
* <p>
* The WebMessageListener invokes callbacks on the UI thread by default. However,
* JavascriptInterface invokes callbacks on a background thread by default. In order to
* guarantee thread safety and that the caller always gets consistent behavior the the callback
* should always be called on the UI thread. To change the default behavior of JavascriptInterface,
* the callback is wrapped in a handler which will tell it to run on the UI thread instead of the default
* background thread it would otherwise be invoked on.
* <p>
* @param webview the component that WebMessageListener or JavascriptInterface will be added to
* @param jsObjName the name that will be given to the Javascript objects created by either
* WebMessageListener or JavascriptInterface
* @param allowedOriginRules a set of origins used only by WebMessageListener, if a frame matches an
* origin in this set then it will have the JS object injected into it
* @param onMessageReceived invoked on UI thread with message passed in from JavaScript postMessage() call
*/
fun createJsObject(
webview: WebView,
jsObjName: String,
allowedOriginRules: Set<String>,
onMessageReceived: (message: String) -> Unit
) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webview, jsObjName, allowedOriginRules
) { _, message, _, _, _ -> onMessageReceived(message.data!!) }
} else {
webview.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(message: String) {
// Use the handler to invoke method on UI thread
handler.post { onMessageReceived(message) }
}
}, jsObjName)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.res.Configuration
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
Expand All @@ -33,15 +30,10 @@ import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewCompat.WebMessageListener
import androidx.webkit.WebViewFeature
import com.android.samples.webviewdemo.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
// Create a handler that runs on the UI thread
private val handler: Handler = Handler(Looper.getMainLooper())

// Creating the custom WebView Client Class
private class MyWebViewClient(private val assetLoader: WebViewAssetLoader) :
WebViewClientCompat() {
Expand All @@ -53,56 +45,6 @@ class MainActivity : AppCompatActivity() {
}
}

/**
* Injects a JavaScript object which supports a {@code postMessage()} method.
* A feature check is used to determine if the preferred API, WebMessageListener, is supported.
* If it is, then WebMessageListener will be used to create a JavaScript object. The object will
* be injected into all of the frames that have an origin matching those in
* `allowedOriginRules`.
* <p>
* If [WebMessageListener] is not supported then the method will defer to using JavascriptInterface
* to create the JavaScript object.
* <p>
* The {@code postMessage()} methods in the Javascript objects created by WebMessageListener and
* JavascriptInterface both make calls to the same callback, {@code onMessageReceived()}.
* In this case, the callback invokes native Android sharing.
* <p>
* The WebMessageListener invokes callbacks on the UI thread by default. However,
* JavascriptInterface invokes callbacks on a background thread by default. In order to
* guarantee thread safety and that the caller always gets consistent behavior the the callback
* should always be called on the UI thread. To change the default behavior of JavascriptInterface,
* the callback is wrapped in a handler which will tell it to run on the UI thread instead of the default
* background thread it would otherwise be invoked on.
* <p>
* @param webview the component that WebMessageListener or JavascriptInterface will be added to
* @param jsObjName the name that will be given to the Javascript objects created by either
* WebMessageListener or JavascriptInterface
* @param allowedOriginRules a set of origins used only by WebMessageListener, if a frame matches an
* origin in this set then it will have the JS object injected into it
* @param onMessageReceived invoked on UI thread with message passed in from JavaScript postMessage() call
*/
@Suppress("SameParameterValue")
private fun createJsObject(
webview: WebView,
jsObjName: String,
allowedOriginRules: Set<String>,
onMessageReceived: (message: String) -> Unit
) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webview, jsObjName, allowedOriginRules
) { _, message, _, _, _ -> onMessageReceived(message.data!!) }
} else {
webview.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(message: String) {
// Use the handler to invoke method on UI thread
handler.post { onMessageReceived(message) }
}
}, jsObjName)
}
}

// Invokes native android sharing
private fun invokeShareIntent(message: String) {
val sendIntent: Intent = Intent().apply {
Expand Down Expand Up @@ -189,7 +131,7 @@ class MainActivity : AppCompatActivity() {
title = getString(R.string.app_name)

// Setup debugging; See https://developers.google.com/web/tools/chrome-devtools/remote-debugging/webviews for reference
if (0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) {
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
WebView.setWebContentsDebuggingEnabled(true)
}

Expand Down

0 comments on commit 8a9927f

Please sign in to comment.