/*
* Copyright (C) 2015 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 androidx.test.espresso.web.sugar;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.matcher.ViewMatchers.isJavascriptEnabled;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static org.hamcrest.Matchers.any;
import android.view.View;
import androidx.annotation.CheckResult;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.remote.annotation.RemoteMsgConstructor;
import androidx.test.espresso.remote.annotation.RemoteMsgField;
import androidx.test.espresso.web.action.AtomAction;
import androidx.test.espresso.web.action.EnableJavascriptAction;
import androidx.test.espresso.web.assertion.WebAssertion;
import androidx.test.espresso.web.model.Atom;
import androidx.test.espresso.web.model.ElementReference;
import androidx.test.espresso.web.model.WindowReference;
import androidx.test.internal.platform.util.TestOutputEmitter;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.annotation.CheckReturnValue;
import javax.annotation.Nullable;
import org.hamcrest.Matcher;
/**
* An Entry Point to work with WebViews on Android.
*
* <p>Similar to onData, WebView interactions are actually composed of several ViewActions. However
* they need to be properly orchestrated and are quite verbose. Web and WebInteraction wrap this
* boilerplate and give an Espresso like feel to interacting with WebViews.
*
* <p>WebView interactions constantly cross the Java/Javascript boundary to do their work, since
* there is no chance of introducing race conditions by exposing data from the Javascript
* environment (everything we see on the Java side is an isolated copy), returning data from
* WebInteractions is fully supported.
*/
public final class Web {
static {
// Also adds the usage data as test output properties. By default it's no-op.
Map<String, Serializable> usageProperties = new HashMap<>();
usageProperties.put("Espresso-Web", "1");
TestOutputEmitter.addOutputProperties(usageProperties);
}
public static WebInteraction<Void> onWebView() {
return onWebView(isJavascriptEnabled());
}
public static WebInteraction<Void> onWebView(Matcher<View> viewMatcher) {
return new WebInteraction<Void>(viewMatcher);
}
private static class Timeout {
private final long timeout;
private final TimeUnit unit;
static final Timeout NONE = new Timeout(-1, TimeUnit.MILLISECONDS, false);
private Timeout(long timeout, TimeUnit unit, boolean check) {
this.timeout = timeout;
this.unit = unit;
if (check) {
checkArgument(timeout > 0);
checkNotNull(unit);
}
}
}
/**
* Analogous to a ViewInteraction or a DataInteraction, a WebInteraction exposes a fluent API to
* the underlying WebView.
*/
public static class WebInteraction<R> {
private final Matcher<View> viewMatcher;
private final boolean brandNew;
@Nullable private final R result;
@Nullable private final WindowReference window;
@Nullable private final ElementReference element;
private final Timeout timeout;
private WebInteraction(Matcher<View> viewMatcher) {
this(viewMatcher, null, null, null, true, new Timeout(10, TimeUnit.SECONDS, true));
}
private WebInteraction(
Matcher<View> viewMatcher,
R result,
WindowReference window,
ElementReference element,
boolean brandNew,
Timeout timeout) {
this.viewMatcher = checkNotNull(viewMatcher);
this.result = result;
this.window = window;
this.element = element;
this.brandNew = brandNew;
this.timeout = timeout;
}
/**
* Removes the Element and Window references from this interaction.
*
* <p>This is usually necessary when a prior action (for example a click) introduces a
* navigation that invalidates the ElementReference and WindowReference pointers.
*/
public WebInteraction<R> reset() {
return new WebInteraction<R>(viewMatcher, result, null, null, brandNew, timeout);
}
/**
* Performs a force enable of Javascript on a WebView.
*
* <p>All WebView interactions are done via Javascript - therefore the WebView we are working on
* must support Javascript evaluation.
*
* <p>Enabling Javascript may cause the WebView under test to be reloaded. This is necessary to
* ensure the test infrastructure javascript bridges are loaded by the WebView.
*/
public WebInteraction<R> forceJavascriptEnabled() {
onView(viewMatcher).perform(new EnableJavascriptAction());
return this;
}
/**
* Disables all Timeouts on this WebInteraction.
*
* <p>Javascript evaluation is performed asynchronously on the WebKit/Chromium thread. By
* default we wait a short while for the result to be delivered back to the test.
*/
@CheckResult
@CheckReturnValue
public WebInteraction<R> withNoTimeout() {
return new WebInteraction<R>(viewMatcher, result, window, element, brandNew, Timeout.NONE);
}
/** Sets a specific timeout for this WebInteraction. */
@CheckResult
@CheckReturnValue
public WebInteraction<R> withTimeout(long amount, TimeUnit unit) {
return new WebInteraction<R>(
viewMatcher, result, window, element, brandNew, new Timeout(amount, unit, true));
}
/**
* Causes this WebInteraction to have it's javascript evaluated in a particular DOM window.
*
* <p>By default Javascript may be evaluated in the main window. However in an application which
* uses frames, you may want to evaluate in another frame.
*/
@CheckResult
@CheckReturnValue
public WebInteraction<R> inWindow(WindowReference window) {
return new WebInteraction<R>(viewMatcher, result, window, element, brandNew, timeout);
}
/**
* Causes this WebInteraction to have it's javascript evaluated in a particular DOM window.
*
* <p>This method accepts an Atom which will be evaluated in the main window to choose a
* particular DOM window for further interactions. This method will block until the the provided
* Atom returns with a result.
*/
@CheckResult
@CheckReturnValue
public WebInteraction<R> inWindow(Atom<WindowReference> windowPicker) {
return new WebInteraction<R>(
viewMatcher, result, doEval(windowPicker, null, null), element, brandNew, timeout);
}
/**
* Causes this WebInteraction to supply the given ElementReference to the Atom prior to
* evaluation.
*
* <p>Calling this method resets any previously selected ElementReference.
*
* <p>{@see Atom#getArguments}
*/
@CheckResult
@CheckReturnValue
public WebInteraction<R> withElement(ElementReference element) {
return new WebInteraction<R>(viewMatcher, result, window, element, brandNew, timeout);
}
/**
* Causes this WebInteraction to supply the given ElementReference to the Atom prior to
* evaluation.
*
* <p>{@see Atom#getArguments}
*
* <p>This method accepts an Atom<ElementReference> which it will evaluate on the current
* context's Window. This method blocks until the evaluation completes.
*
* <p>Calling this method resets any previously selected ElementReference.
*
* <p>If you want to evaluate the elementPicker in the context of the previously selected
* ElementReference {@see #withContextualElement}
*/
@CheckResult
@CheckReturnValue
public WebInteraction<R> withElement(Atom<ElementReference> elementPicker) {
return new WebInteraction<R>(
viewMatcher, result, window, doEval(elementPicker, window, null), brandNew, timeout);
}
/**
* Allows for contextually evaluating this WebInteraction with the selected element.
*
* <p>Specifically the elementPicker atom will be evaluated with the CURRENTLY selected element
* to discover the new element to work against.
*
* <p>This allows callers to interact with a document that looks like this:
*
* <pre>
* {@literal
* <div id="teacher">
* <div id="person_name">
* <p>Socrates</p>
* </div>
* </div>
* <div id="student">
* <div id="person_name">
* <p>Plato</p>
* </div>
* </div>
* }
* </pre>
*
* With code like this:
*
* <pre>{@code
* onWebView()
* .withElement(findElement(Locator.ID, "teacher"))
* .withContextualElement(findElement(Locator.ID, "person_name"))
* .check(webMatches(getText(), containsString("Socrates")));
* }</pre>
*/
@CheckResult
@CheckReturnValue
public WebInteraction<R> withContextualElement(Atom<ElementReference> elementPicker) {
return new WebInteraction<R>(
viewMatcher, result, window, doEval(elementPicker, window, element), brandNew, timeout);
}
/**
* Executes the provided atom within the current context (the combination of Window and Element
* References).
*
* <p>This method blocks until the Atom returns. The result of the Atom's evaluation is used to
* create a new instance of WebInteraction which can be used to access the result of the Atom's
* evaluation.
*/
public <E> WebInteraction<E> perform(Atom<E> atom) {
E newResult = doEval(atom, window, element);
return new WebInteraction<E>(viewMatcher, newResult, window, element, false, timeout);
}
/**
* Evaluates the given WebAssertion.
*
* <p>The WebAssertion's atom is evaluated, after it's evaluation completes, the WebAssertion is
* run on the main thread to perform further checks. The WebAssertion is given the Atom's result
* and the WebView it had run against.
*
* <p>After this method completes, the result of the atom's evaluation is avaliable via get.
*/
public <E> WebInteraction<E> check(WebAssertion<E> assertion) {
E newResult = doEval(assertion.getAtom(), window, element);
onView(viewMatcher).check(assertion.toViewAssertion(newResult));
return new WebInteraction<E>(viewMatcher, newResult, window, element, false, timeout);
}
private <E> E doEval(Atom<E> atom, WindowReference window, ElementReference elem) {
checkNotNull(atom, "Need an atom!");
AtomAction<E> atomAction = new AtomAction(atom, window, elem);
onView(viewMatcher).perform(atomAction);
try {
if (timeout == Timeout.NONE) {
return atomAction.get();
} else {
return atomAction.get(timeout.timeout, timeout.unit);
}
} catch (ExecutionException ee) {
onView(viewMatcher).perform(new ExceptionPropagator(ee.getCause()));
return null; // always throws.
} catch (InterruptedException ie) {
onView(viewMatcher).perform(new ExceptionPropagator(ie));
return null; // always throws.
} catch (TimeoutException te) {
onView(viewMatcher).perform(new ExceptionPropagator(te));
return null; // always throws.
} catch (RuntimeException re) {
onView(viewMatcher).perform(new ExceptionPropagator(re));
return null; // always throws.
}
}
/** Returns the result of a prior call to perform or check. */
public R get() {
checkState(!brandNew, "Perform or Check never called on this WebInteraction!");
return result;
}
static class ExceptionPropagator implements ViewAction {
@RemoteMsgField(order = 0)
private final RuntimeException error;
@RemoteMsgConstructor
public ExceptionPropagator(RuntimeException error) {
this.error = checkNotNull(error);
}
public ExceptionPropagator(Throwable t) {
this(new RuntimeException(t));
}
@Override
public String getDescription() {
return "Propagate: " + error;
}
@Override
public void perform(UiController uiController, View view) {
throw error;
}
@SuppressWarnings("unchecked")
@Override
public Matcher<View> getConstraints() {
return any(View.class);
}
}
}
}