diff options
Diffstat (limited to 'mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java')
-rw-r--r-- | mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java | 2915 |
1 files changed, 2915 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java new file mode 100644 index 0000000000..81133bb063 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java @@ -0,0 +1,2915 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test.rule; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +import android.app.Instrumentation; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.SurfaceTexture; +import android.location.Criteria; +import android.location.Location; +import android.location.LocationManager; +import android.os.SystemClock; +import android.util.Log; +import android.util.Pair; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.Surface; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.platform.app.InstrumentationRegistry; +import java.io.File; +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KClass; +import org.hamcrest.Matcher; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.junit.rules.ErrorCollector; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.mozilla.gecko.MultiMap; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.Autocomplete; +import org.mozilla.geckoview.Autofill; +import org.mozilla.geckoview.ContentBlocking; +import org.mozilla.geckoview.GeckoDisplay; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntime.ActivityDelegate; +import org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoSession.ContentDelegate; +import org.mozilla.geckoview.GeckoSession.HistoryDelegate; +import org.mozilla.geckoview.GeckoSession.MediaDelegate; +import org.mozilla.geckoview.GeckoSession.NavigationDelegate; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate; +import org.mozilla.geckoview.GeckoSession.PrintDelegate; +import org.mozilla.geckoview.GeckoSession.ProgressDelegate; +import org.mozilla.geckoview.GeckoSession.PromptDelegate; +import org.mozilla.geckoview.GeckoSession.ScrollDelegate; +import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate; +import org.mozilla.geckoview.GeckoSession.TextInputDelegate; +import org.mozilla.geckoview.GeckoSessionSettings; +import org.mozilla.geckoview.MediaSession; +import org.mozilla.geckoview.OrientationController; +import org.mozilla.geckoview.RuntimeTelemetry; +import org.mozilla.geckoview.SessionTextInput; +import org.mozilla.geckoview.WebExtension; +import org.mozilla.geckoview.WebExtensionController; +import org.mozilla.geckoview.WebNotificationDelegate; +import org.mozilla.geckoview.WebPushDelegate; +import org.mozilla.geckoview.test.GeckoViewTestActivity; +import org.mozilla.geckoview.test.util.Environment; +import org.mozilla.geckoview.test.util.RuntimeCreator; +import org.mozilla.geckoview.test.util.TestServer; +import org.mozilla.geckoview.test.util.UiThreadUtils; + +/** + * TestRule that, for each test, sets up a GeckoSession, runs the test on the UI thread, and tears + * down the GeckoSession at the end of the test. The rule also provides methods for waiting on + * particular callbacks to be called, and methods for asserting that callbacks are called in the + * proper order. + */ +public class GeckoSessionTestRule implements TestRule { + private static final String LOGTAG = "GeckoSessionTestRule"; + + public static final int TEST_PORT = 4245; + public static final String TEST_HOST = "localhost"; + public static final String TEST_ENDPOINT = "http://" + TEST_HOST + ":" + TEST_PORT; + + private static final Method sOnPageStart; + private static final Method sOnPageStop; + private static final Method sOnNewSession; + private static final Method sOnCrash; + private static final Method sOnKill; + + static { + try { + sOnPageStart = + GeckoSession.ProgressDelegate.class.getMethod( + "onPageStart", GeckoSession.class, String.class); + sOnPageStop = + GeckoSession.ProgressDelegate.class.getMethod( + "onPageStop", GeckoSession.class, boolean.class); + sOnNewSession = + GeckoSession.NavigationDelegate.class.getMethod( + "onNewSession", GeckoSession.class, String.class); + sOnCrash = GeckoSession.ContentDelegate.class.getMethod("onCrash", GeckoSession.class); + sOnKill = GeckoSession.ContentDelegate.class.getMethod("onKill", GeckoSession.class); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + public void addDisplay(final GeckoSession session, final int x, final int y) { + final GeckoDisplay display = session.acquireDisplay(); + + final SurfaceTexture displayTexture = new SurfaceTexture(0); + displayTexture.setDefaultBufferSize(x, y); + + final Surface displaySurface = new Surface(displayTexture); + display.surfaceChanged(new GeckoDisplay.SurfaceInfo.Builder(displaySurface).size(x, y).build()); + + mDisplays.put(session, display); + mDisplayTextures.put(session, displayTexture); + mDisplaySurfaces.put(session, displaySurface); + } + + public void releaseDisplay(final GeckoSession session) { + if (!mDisplays.containsKey(session)) { + // No display to release + return; + } + final GeckoDisplay display = mDisplays.remove(session); + display.surfaceDestroyed(); + session.releaseDisplay(display); + final Surface displaySurface = mDisplaySurfaces.remove(session); + displaySurface.release(); + final SurfaceTexture displayTexture = mDisplayTextures.remove(session); + displayTexture.release(); + } + + /** + * Specify the timeout for any of the wait methods, in milliseconds, relative to {@link + * Environment#DEFAULT_TIMEOUT_MILLIS}. When the default timeout scales to account for differences + * in the device under test, the timeout value here will be scaled as well. Can be used on classes + * or methods. + */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface TimeoutMillis { + long value(); + } + + /** Specify the display size for the GeckoSession in device pixels */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface WithDisplay { + int width(); + + int height(); + } + + /** Specify that the main session should not be opened at the start of the test. */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface ClosedSessionAtStart { + boolean value() default true; + } + + /** + * Specify that the test will set a delegate to null when creating a session, rather than setting + * the delegate to a proxy. The test cannot wait on any delegates that are set to null. + */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface NullDelegate { + Class<?> value(); + + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @interface List { + NullDelegate[] value(); + } + } + + /** + * Specify a list of GeckoSession settings to be applied to the GeckoSession object under test. + * Can be used on classes or methods. Note that the settings values must be string literals + * regardless of the type of the settings. + * + * <p>Enable tracking protection for a particular test: + * + * <pre> + * @Setting.List(@Setting(key = Setting.Key.USE_TRACKING_PROTECTION, + * value = "false")) + * @Test public void test() { ... } + * </pre> + * + * <p>Use multiple settings: + * + * <pre> + * @Setting.List({@Setting(key = Setting.Key.USE_PRIVATE_MODE, + * value = "true"), + * @Setting(key = Setting.Key.USE_TRACKING_PROTECTION, + * value = "false")}) + * </pre> + */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Setting { + enum Key { + CHROME_URI, + DISPLAY_MODE, + ALLOW_JAVASCRIPT, + SCREEN_ID, + USE_PRIVATE_MODE, + USE_TRACKING_PROTECTION, + FULL_ACCESSIBILITY_TREE; + + private final GeckoSessionSettings.Key<?> mKey; + private final Class<?> mType; + + Key() { + final Field field; + try { + field = GeckoSessionSettings.class.getDeclaredField(name()); + field.setAccessible(true); + mKey = (GeckoSessionSettings.Key<?>) field.get(null); + } catch (final NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + + final ParameterizedType genericType = (ParameterizedType) field.getGenericType(); + mType = (Class<?>) genericType.getActualTypeArguments()[0]; + } + + @SuppressWarnings("unchecked") + public void set(final GeckoSessionSettings settings, final String value) { + try { + if (boolean.class.equals(mType) || Boolean.class.equals(mType)) { + final Method method = + GeckoSessionSettings.class.getDeclaredMethod( + "setBoolean", GeckoSessionSettings.Key.class, boolean.class); + method.setAccessible(true); + method.invoke(settings, mKey, Boolean.valueOf(value)); + } else if (int.class.equals(mType) || Integer.class.equals(mType)) { + final Method method = + GeckoSessionSettings.class.getDeclaredMethod( + "setInt", GeckoSessionSettings.Key.class, int.class); + method.setAccessible(true); + try { + method.invoke( + settings, mKey, (Integer) GeckoSessionSettings.class.getField(value).get(null)); + } catch (final NoSuchFieldException | IllegalAccessException | ClassCastException e) { + method.invoke(settings, mKey, Integer.valueOf(value)); + } + } else if (String.class.equals(mType)) { + final Method method = + GeckoSessionSettings.class.getDeclaredMethod( + "setString", GeckoSessionSettings.Key.class, String.class); + method.setAccessible(true); + method.invoke(settings, mKey, value); + } else { + throw new IllegalArgumentException("Unsupported type: " + mType.getSimpleName()); + } + } catch (final NoSuchMethodException + | IllegalAccessException + | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + } + + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @interface List { + Setting[] value(); + } + + Key key(); + + String value(); + } + + /** + * Assert that a method is called or not called, and if called, the order and number of times it + * is called. The order number is a monotonically increasing integer; if an called method's order + * number is less than the current order number, an exception is raised for out-of-order call. + * + * <p>{@code @AssertCalled} asserts the method must be called at least once. + * + * <p>{@code @AssertCalled(false)} asserts the method must not be called. + * + * <p>{@code @AssertCalled(order = 2)} asserts the method must be called once and after any other + * method with order number less than 2. + * + * <p>{@code @AssertCalled(order = {2, 4})} asserts order number 2 for first call and order number + * 4 for any subsequent calls. + * + * <p>{@code @AssertCalled(count = 2)} asserts two calls total in any order with respect to other + * calls. + * + * <p>{@code @AssertCalled(count = 2, order = 2)} asserts two calls, both with order number 2. + * + * <p>{@code @AssertCalled(count = 2, order = {2, 4, 6})} asserts two calls total: the first with + * order number 2 and the second with order number 4. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + public @interface AssertCalled { + /** + * @return True if the method must be called if count != 0, or false if the method must not be + * called. + */ + boolean value() default true; + + /** + * @return The number of calls allowed. Specify -1 to allow any number > 0. Specify 0 to assert + * the method is not called, even if value() is true. + */ + int count() default -1; + + /** + * @return If called, the order number for each call, or 0 to allow arbitrary order. If order's + * length is more than count, extra elements are not used; if order's length is less than + * count, the last element is repeated. + */ + int[] order() default 0; + } + + /** Interface that represents a function that registers or unregisters a delegate. */ + public interface DelegateRegistrar<T> { + void invoke(T delegate) throws Throwable; + } + + /* + * If the value here is true, content crashes will be ignored. If false, the test will + * be failed immediately if a content crash occurs. This is also the case when + * {@link IgnoreCrash} is not present. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + public @interface IgnoreCrash { + /** + * @return True if content crashes should be ignored, false otherwise. Default is true. + */ + boolean value() default true; + } + + public static class ChildCrashedException extends RuntimeException { + public ChildCrashedException(final String detailMessage) { + super(detailMessage); + } + } + + public static class RejectedPromiseException extends RuntimeException { + private final Object mReason; + + /* package */ RejectedPromiseException(final Object reason) { + super(String.valueOf(reason)); + mReason = reason; + } + + public Object getReason() { + return mReason; + } + } + + public static class CallRequirement { + public final boolean allowed; + public final int count; + public final int[] order; + + public CallRequirement(final boolean allowed, final int count, final int[] order) { + this.allowed = allowed; + this.count = count; + this.order = order; + } + } + + public static class CallInfo { + public final int counter; + public final int order; + + /* package */ CallInfo(final int counter, final int order) { + this.counter = counter; + this.order = order; + } + } + + public static class MethodCall { + public final GeckoSession session; + public final Method method; + public final CallRequirement requirement; + public final Object target; + private int currentCount; + + public MethodCall( + final GeckoSession session, final Method method, final CallRequirement requirement) { + this(session, method, requirement, /* target */ null); + } + + /* package */ MethodCall( + final GeckoSession session, + final Method method, + final AssertCalled annotation, + final Object target) { + this( + session, + method, + (annotation != null) + ? new CallRequirement(annotation.value(), annotation.count(), annotation.order()) + : null, + /* target */ target); + } + + /* package */ MethodCall( + final GeckoSession session, + final Method method, + final CallRequirement requirement, + final Object target) { + this.session = session; + this.method = method; + this.requirement = requirement; + this.target = target; + currentCount = 0; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } else if (other instanceof MethodCall) { + final MethodCall otherCall = (MethodCall) other; + return (session == null || otherCall.session == null || session.equals(otherCall.session)) + && methodsEqual(method, ((MethodCall) other).method); + } else if (other instanceof Method) { + return methodsEqual(method, (Method) other); + } + return false; + } + + @Override + public int hashCode() { + return method.hashCode(); + } + + /* package */ int getOrder() { + if (requirement == null || currentCount == 0) { + return 0; + } + + final int[] order = requirement.order; + if (order == null || order.length == 0) { + return 0; + } + return order[Math.min(currentCount - 1, order.length - 1)]; + } + + /* package */ int getCount() { + return (requirement == null) ? -1 : requirement.allowed ? requirement.count : 0; + } + + /* package */ void incrementCounter() { + currentCount++; + } + + /* package */ int getCurrentCount() { + return currentCount; + } + + /* package */ boolean allowUnlimitedCalls() { + return getCount() == -1; + } + + /* package */ boolean allowMoreCalls() { + final int count = getCount(); + return count == -1 || count > currentCount; + } + + /* package */ CallInfo getInfo() { + return new CallInfo(currentCount, getOrder()); + } + + // Similar to Method.equals, but treat the same method from an interface and an + // overriding class as the same (e.g. CharSequence.length == String.length). + private static boolean methodsEqual(final @NonNull Method m1, final @NonNull Method m2) { + return (m1.getDeclaringClass().isAssignableFrom(m2.getDeclaringClass()) + || m2.getDeclaringClass().isAssignableFrom(m1.getDeclaringClass())) + && m1.getName().equals(m2.getName()) + && m1.getReturnType().equals(m2.getReturnType()) + && Arrays.equals(m1.getParameterTypes(), m2.getParameterTypes()); + } + } + + protected static class CallRecord { + public final Method method; + public final MethodCall methodCall; + public final Object[] args; + + public CallRecord(final GeckoSession session, final Method method, final Object[] args) { + this.method = method; + this.methodCall = new MethodCall(session, method, /* requirement */ null); + this.args = args; + } + } + + protected interface CallRecordHandler { + boolean handleCall(Method method, Object[] args); + } + + protected final class ExternalDelegate<T> { + public final Class<T> delegate; + private final DelegateRegistrar<T> mRegister; + private final DelegateRegistrar<T> mUnregister; + private final T mProxy; + private boolean mRegistered; + + public ExternalDelegate( + final Class<T> delegate, + final T impl, + final DelegateRegistrar<T> register, + final DelegateRegistrar<T> unregister) { + this.delegate = delegate; + mRegister = register; + mUnregister = unregister; + + @SuppressWarnings("unchecked") + final T delegateProxy = + (T) + Proxy.newProxyInstance( + getClass().getClassLoader(), + impl.getClass().getInterfaces(), + Proxy.getInvocationHandler(mCallbackProxy)); + mProxy = delegateProxy; + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + @Override + public boolean equals(final Object obj) { + return obj instanceof ExternalDelegate<?> + && delegate.equals(((ExternalDelegate<?>) obj).delegate); + } + + public void register() { + try { + if (!mRegistered) { + mRegister.invoke(mProxy); + mRegistered = true; + } + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } + } + + public void unregister() { + try { + if (mRegistered) { + mUnregister.invoke(mProxy); + mRegistered = false; + } + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } + } + } + + protected class CallbackDelegates { + private final Map<Pair<GeckoSession, Method>, MethodCall> mDelegates = new HashMap<>(); + private final List<ExternalDelegate<?>> mExternalDelegates = new ArrayList<>(); + private int mOrder; + private JSONObject mOldPrefs; + + public void delegate(final @Nullable GeckoSession session, final @NonNull Object callback) { + for (final Class<?> ifce : mAllDelegates) { + if (!ifce.isInstance(callback)) { + continue; + } + assertThat("Cannot delegate null-delegate callbacks", ifce, not(isIn(mNullDelegates))); + addDelegatesForInterface(session, callback, ifce); + } + } + + private void addDelegatesForInterface( + @Nullable final GeckoSession session, + @NonNull final Object callback, + @NonNull final Class<?> ifce) { + for (final Method method : ifce.getMethods()) { + final Method callbackMethod; + try { + callbackMethod = + callback.getClass().getMethod(method.getName(), method.getParameterTypes()); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + final Pair<GeckoSession, Method> pair = new Pair<>(session, method); + final MethodCall call = + new MethodCall( + session, callbackMethod, getAssertCalled(callbackMethod, callback), callback); + // It's unclear if we should assert the call count if we replace an existing + // delegate half way through. Until that is resolved, forbid replacing an + // existing delegate during a test. If you are thinking about changing this + // behavior, first see if #delegateDuringNextWait fits your needs. + assertThat("Cannot replace an existing delegate", mDelegates, not(hasKey(pair))); + mDelegates.put(pair, call); + } + } + + public <T> ExternalDelegate<T> addExternalDelegate( + @NonNull final Class<T> delegate, + @NonNull final DelegateRegistrar<T> register, + @NonNull final DelegateRegistrar<T> unregister, + @NonNull final T impl) { + assertThat("Delegate must be an interface", delegate.isInterface(), equalTo(true)); + + // Delegate each interface to the real thing, then register the delegate using our + // proxy. That way all calls to the delegate are recorded just like our internal + // delegates. + addDelegatesForInterface(/* session */ null, impl, delegate); + + final ExternalDelegate<T> externalDelegate = + new ExternalDelegate<>(delegate, impl, register, unregister); + mExternalDelegates.add(externalDelegate); + mAllDelegates.add(delegate); + return externalDelegate; + } + + @NonNull + public List<ExternalDelegate<?>> getExternalDelegates() { + return mExternalDelegates; + } + + /** Generate a JS function to set new prefs and return a set of saved prefs. */ + public void setPrefs(final @NonNull Map<String, ?> prefs) { + mOldPrefs = + (JSONObject) + webExtensionApiCall( + "SetPrefs", + args -> { + final JSONObject existingPrefs = + mOldPrefs != null ? mOldPrefs : new JSONObject(); + + final JSONObject newPrefs = new JSONObject(); + for (final Map.Entry<String, ?> pref : prefs.entrySet()) { + final Object value = pref.getValue(); + if (value instanceof Boolean + || value instanceof Number + || value instanceof CharSequence) { + newPrefs.put(pref.getKey(), value); + } else { + throw new IllegalArgumentException("Unsupported pref value: " + value); + } + } + + args.put("oldPrefs", existingPrefs); + args.put("newPrefs", newPrefs); + }); + } + + /** Generate a JS function to set new prefs and reset a set of saved prefs. */ + private void restorePrefs() { + if (mOldPrefs == null) { + return; + } + + webExtensionApiCall( + "RestorePrefs", + args -> { + args.put("oldPrefs", mOldPrefs); + mOldPrefs = null; + }); + } + + public void clear() { + for (int i = mExternalDelegates.size() - 1; i >= 0; i--) { + mExternalDelegates.get(i).unregister(); + } + mExternalDelegates.clear(); + mDelegates.clear(); + mOrder = 0; + + restorePrefs(); + } + + public void clearAndAssert() { + final Collection<MethodCall> values = mDelegates.values(); + final MethodCall[] valuesArray = values.toArray(new MethodCall[values.size()]); + + clear(); + + for (final MethodCall call : valuesArray) { + assertMatchesCount(call); + } + } + + public MethodCall prepareMethodCall(final GeckoSession session, final Method method) { + MethodCall call = mDelegates.get(new Pair<>(session, method)); + if (call == null && session != null) { + call = mDelegates.get(new Pair<>((GeckoSession) null, method)); + } + if (call == null) { + return null; + } + + assertAllowMoreCalls(call); + call.incrementCounter(); + assertOrder(call, mOrder); + mOrder = Math.max(call.getOrder(), mOrder); + return call; + } + } + + /* package */ static AssertCalled getAssertCalled(final Method method, final Object callback) { + final AssertCalled annotation = method.getAnnotation(AssertCalled.class); + if (annotation != null) { + return annotation; + } + + // Some Kotlin lambdas have an invoke method that carries the annotation, + // instead of the interface method carrying the annotation. + try { + return callback + .getClass() + .getDeclaredMethod("invoke", method.getParameterTypes()) + .getAnnotation(AssertCalled.class); + } catch (final NoSuchMethodException e) { + return null; + } + } + + private static final Set<Class<?>> DEFAULT_DELEGATES = new HashSet<>(); + + static { + DEFAULT_DELEGATES.add(Autofill.Delegate.class); + DEFAULT_DELEGATES.add(ContentBlocking.Delegate.class); + DEFAULT_DELEGATES.add(ContentDelegate.class); + DEFAULT_DELEGATES.add(HistoryDelegate.class); + DEFAULT_DELEGATES.add(MediaDelegate.class); + DEFAULT_DELEGATES.add(MediaSession.Delegate.class); + DEFAULT_DELEGATES.add(NavigationDelegate.class); + DEFAULT_DELEGATES.add(PermissionDelegate.class); + DEFAULT_DELEGATES.add(PrintDelegate.class); + DEFAULT_DELEGATES.add(ProgressDelegate.class); + DEFAULT_DELEGATES.add(PromptDelegate.class); + DEFAULT_DELEGATES.add(ScrollDelegate.class); + DEFAULT_DELEGATES.add(SelectionActionDelegate.class); + DEFAULT_DELEGATES.add(TextInputDelegate.class); + } + + private static final Set<Class<?>> DEFAULT_RUNTIME_DELEGATES = new HashSet<>(); + + static { + DEFAULT_RUNTIME_DELEGATES.add(Autocomplete.StorageDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(ActivityDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(GeckoRuntime.Delegate.class); + DEFAULT_RUNTIME_DELEGATES.add(OrientationController.OrientationDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(ServiceWorkerDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(WebNotificationDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(WebExtensionController.PromptDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(WebPushDelegate.class); + } + + private static class DefaultImpl + implements + // Session delegates + Autofill.Delegate, + ContentBlocking.Delegate, + ContentDelegate, + HistoryDelegate, + MediaDelegate, + MediaSession.Delegate, + NavigationDelegate, + PermissionDelegate, + PrintDelegate, + ProgressDelegate, + PromptDelegate, + ScrollDelegate, + SelectionActionDelegate, + TextInputDelegate, + // Runtime delegates + ActivityDelegate, + Autocomplete.StorageDelegate, + GeckoRuntime.Delegate, + OrientationController.OrientationDelegate, + ServiceWorkerDelegate, + WebExtensionController.PromptDelegate, + WebNotificationDelegate, + WebPushDelegate { + @Override + public GeckoResult<Intent> onStartActivityForResult(@NonNull PendingIntent intent) { + return null; + } + + // The default impl of this will call `onLocationChange(2)` which causes duplicated + // call records, to avoid that we implement it here so that it doesn't do anything. + @Override + public void onLocationChange( + @NonNull GeckoSession session, + @Nullable String url, + @NonNull List<ContentPermission> perms) {} + + @Override + public void onShutdown() {} + + @Override + public GeckoResult<GeckoSession> onOpenWindow(@NonNull String url) { + return GeckoResult.fromValue(null); + } + } + + private static final DefaultImpl DEFAULT_IMPL = new DefaultImpl(); + + public final Environment env = new Environment(); + + protected final Instrumentation mInstrumentation = InstrumentationRegistry.getInstrumentation(); + protected final GeckoSessionSettings mDefaultSettings; + protected final Set<GeckoSession> mSubSessions = new HashSet<>(); + + protected ErrorCollector mErrorCollector; + protected GeckoSession mMainSession; + protected Object mCallbackProxy; + protected Set<Class<?>> mNullDelegates; + protected Set<Class<?>> mAllDelegates; + protected List<CallRecord> mCallRecords; + protected CallRecordHandler mCallRecordHandler; + protected CallbackDelegates mWaitScopeDelegates; + protected CallbackDelegates mTestScopeDelegates; + protected int mLastWaitStart; + protected int mLastWaitEnd; + protected MethodCall mCurrentMethodCall; + protected long mTimeoutMillis; + protected Point mDisplaySize; + protected Map<GeckoSession, SurfaceTexture> mDisplayTextures = new HashMap<>(); + protected Map<GeckoSession, Surface> mDisplaySurfaces = new HashMap<>(); + protected Map<GeckoSession, GeckoDisplay> mDisplays = new HashMap<>(); + protected boolean mClosedSession; + protected boolean mIgnoreCrash; + + public GeckoSessionTestRule() { + mDefaultSettings = new GeckoSessionSettings.Builder().build(); + } + + /** + * Set an ErrorCollector for assertion errors, or null to not use one. + * + * @param ec ErrorCollector or null. + */ + public void setErrorCollector(final @Nullable ErrorCollector ec) { + mErrorCollector = ec; + } + + /** + * Get the current ErrorCollector, or null if not using one. + * + * @return ErrorCollector or null. + */ + public @Nullable ErrorCollector getErrorCollector() { + return mErrorCollector; + } + + /** + * Get the current timeout value in milliseconds. + * + * @return The current timeout value in milliseconds. + */ + public long getTimeoutMillis() { + return mTimeoutMillis; + } + + /** + * Assert a condition with junit.Assert or an error collector. + * + * @param reason Reason string + * @param value Value to check + * @param matcher Matcher for checking the value + */ + public <T> void checkThat(final String reason, final T value, final Matcher<? super T> matcher) { + if (mErrorCollector != null) { + mErrorCollector.checkThat(reason, value, matcher); + } else { + assertThat(reason, value, matcher); + } + } + + private void assertAllowMoreCalls(final MethodCall call) { + final int count = call.getCount(); + if (count != -1) { + checkThat( + call.method.getName() + " call count should be within limit", + call.getCurrentCount() + 1, + lessThanOrEqualTo(count)); + } + } + + private void assertOrder(final MethodCall call, final int order) { + final int newOrder = call.getOrder(); + if (newOrder != 0) { + checkThat( + call.method.getName() + " should be in order", newOrder, greaterThanOrEqualTo(order)); + } + } + + private void assertMatchesCount(final MethodCall call) { + if (call.requirement == null) { + return; + } + final int count = call.getCount(); + if (count == 0) { + checkThat( + call.method.getName() + " should not be called", call.getCurrentCount(), equalTo(0)); + } else if (count == -1) { + checkThat( + call.method.getName() + " should be called", call.getCurrentCount(), greaterThan(0)); + } else { + checkThat( + call.method.getName() + " should be called specified number of times", + call.getCurrentCount(), + equalTo(count)); + } + } + + /** + * Get the session set up for the current test. + * + * @return GeckoSession object. + */ + public @NonNull GeckoSession getSession() { + return mMainSession; + } + + /** + * Get the runtime set up for the current test. + * + * @return GeckoRuntime object. + */ + public @NonNull GeckoRuntime getRuntime() { + return RuntimeCreator.getRuntime(); + } + + public void setTelemetryDelegate(final RuntimeTelemetry.Delegate delegate) { + RuntimeCreator.setTelemetryDelegate(delegate); + } + + public @Nullable GeckoDisplay getDisplay() { + return mDisplays.get(mMainSession); + } + + protected static void setDelegate( + final @NonNull Class<?> cls, + final @NonNull GeckoSession session, + final @Nullable Object delegate) + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + if (cls == GeckoSession.TextInputDelegate.class) { + session.getTextInput().setDelegate((TextInputDelegate) delegate); + } else if (cls == ContentBlocking.Delegate.class) { + session.setContentBlockingDelegate((ContentBlocking.Delegate) delegate); + } else if (cls == Autofill.Delegate.class) { + session.setAutofillDelegate((Autofill.Delegate) delegate); + } else if (cls == MediaSession.Delegate.class) { + session.setMediaSessionDelegate((MediaSession.Delegate) delegate); + } else { + GeckoSession.class.getMethod("set" + cls.getSimpleName(), cls).invoke(session, delegate); + } + } + + protected static void setRuntimeDelegate( + final @NonNull Class<?> cls, + final @NonNull GeckoRuntime runtime, + final @Nullable Object delegate) { + if (cls == Autocomplete.StorageDelegate.class) { + runtime.setAutocompleteStorageDelegate((Autocomplete.StorageDelegate) delegate); + } else if (cls == ActivityDelegate.class) { + runtime.setActivityDelegate((ActivityDelegate) delegate); + } else if (cls == GeckoRuntime.Delegate.class) { + runtime.setDelegate((GeckoRuntime.Delegate) delegate); + } else if (cls == OrientationController.OrientationDelegate.class) { + runtime + .getOrientationController() + .setDelegate((OrientationController.OrientationDelegate) delegate); + } else if (cls == ServiceWorkerDelegate.class) { + runtime.setServiceWorkerDelegate((ServiceWorkerDelegate) delegate); + } else if (cls == WebNotificationDelegate.class) { + runtime.setWebNotificationDelegate((WebNotificationDelegate) delegate); + } else if (cls == WebExtensionController.PromptDelegate.class) { + runtime + .getWebExtensionController() + .setPromptDelegate((WebExtensionController.PromptDelegate) delegate); + } else if (cls == WebPushDelegate.class) { + runtime.getWebPushController().setDelegate((WebPushDelegate) delegate); + } else { + throw new IllegalStateException("Unknown runtime delegate " + cls.getName()); + } + } + + protected static Object getRuntimeDelegate( + final @NonNull Class<?> cls, final @NonNull GeckoRuntime runtime) { + if (cls == Autocomplete.StorageDelegate.class) { + return runtime.getAutocompleteStorageDelegate(); + } else if (cls == ActivityDelegate.class) { + return runtime.getActivityDelegate(); + } else if (cls == GeckoRuntime.Delegate.class) { + return runtime.getDelegate(); + } else if (cls == OrientationController.OrientationDelegate.class) { + return runtime.getOrientationController().getDelegate(); + } else if (cls == ServiceWorkerDelegate.class) { + return runtime.getServiceWorkerDelegate(); + } else if (cls == WebNotificationDelegate.class) { + return runtime.getWebNotificationDelegate(); + } else if (cls == WebExtensionController.PromptDelegate.class) { + return runtime.getWebExtensionController().getPromptDelegate(); + } else if (cls == WebPushDelegate.class) { + return runtime.getWebPushController().getDelegate(); + } else { + throw new IllegalStateException("Unknown runtime delegate " + cls.getName()); + } + } + + protected static Object getDelegate( + final @NonNull Class<?> cls, final @NonNull GeckoSession session) + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + if (cls == GeckoSession.TextInputDelegate.class) { + return SessionTextInput.class.getMethod("getDelegate").invoke(session.getTextInput()); + } + if (cls == ContentBlocking.Delegate.class) { + return GeckoSession.class.getMethod("getContentBlockingDelegate").invoke(session); + } + if (cls == Autofill.Delegate.class) { + return GeckoSession.class.getMethod("getAutofillDelegate").invoke(session); + } + if (cls == MediaSession.Delegate.class) { + return GeckoSession.class.getMethod("getMediaSessionDelegate").invoke(session); + } + return GeckoSession.class.getMethod("get" + cls.getSimpleName()).invoke(session); + } + + @NonNull + private Set<Class<?>> getCurrentDelegates() { + final List<ExternalDelegate<?>> waitDelegates = mWaitScopeDelegates.getExternalDelegates(); + final List<ExternalDelegate<?>> testDelegates = mTestScopeDelegates.getExternalDelegates(); + + final Set<Class<?>> set = new HashSet<>(DEFAULT_DELEGATES); + set.addAll(DEFAULT_RUNTIME_DELEGATES); + + for (final ExternalDelegate<?> delegate : waitDelegates) { + set.add(delegate.delegate); + } + for (final ExternalDelegate<?> delegate : testDelegates) { + set.add(delegate.delegate); + } + return set; + } + + private void addNullDelegate(final Class<?> delegate) { + assertThat( + "Null-delegate must be valid interface class", + delegate, + either(isIn(DEFAULT_DELEGATES)).or(isIn(DEFAULT_RUNTIME_DELEGATES))); + mNullDelegates.add(delegate); + } + + protected void applyAnnotations( + final Collection<Annotation> annotations, final GeckoSessionSettings settings) { + for (final Annotation annotation : annotations) { + if (TimeoutMillis.class.equals(annotation.annotationType())) { + // Scale timeout based on the default timeout to account for the device under test. + final long value = ((TimeoutMillis) annotation).value(); + final long timeout = + value * env.getScaledTimeoutMillis() / Environment.DEFAULT_TIMEOUT_MILLIS; + mTimeoutMillis = Math.max(timeout, 1000); + } else if (Setting.class.equals(annotation.annotationType())) { + ((Setting) annotation).key().set(settings, ((Setting) annotation).value()); + } else if (Setting.List.class.equals(annotation.annotationType())) { + for (final Setting setting : ((Setting.List) annotation).value()) { + setting.key().set(settings, setting.value()); + } + } else if (NullDelegate.class.equals(annotation.annotationType())) { + addNullDelegate(((NullDelegate) annotation).value()); + } else if (NullDelegate.List.class.equals(annotation.annotationType())) { + for (final NullDelegate nullDelegate : ((NullDelegate.List) annotation).value()) { + addNullDelegate(nullDelegate.value()); + } + } else if (WithDisplay.class.equals(annotation.annotationType())) { + final WithDisplay displaySize = (WithDisplay) annotation; + mDisplaySize = new Point(displaySize.width(), displaySize.height()); + } else if (ClosedSessionAtStart.class.equals(annotation.annotationType())) { + mClosedSession = ((ClosedSessionAtStart) annotation).value(); + } else if (IgnoreCrash.class.equals(annotation.annotationType())) { + mIgnoreCrash = ((IgnoreCrash) annotation).value(); + } + } + } + + private static RuntimeException unwrapRuntimeException(final Throwable e) { + final Throwable cause = e.getCause(); + if (cause != null && cause instanceof RuntimeException) { + return (RuntimeException) cause; + } else if (e instanceof RuntimeException) { + return (RuntimeException) e; + } + + return new RuntimeException(cause != null ? cause : e); + } + + protected void prepareStatement(final Description description) { + final GeckoSessionSettings settings = new GeckoSessionSettings(mDefaultSettings); + mTimeoutMillis = env.getDefaultTimeoutMillis(); + mNullDelegates = new HashSet<>(); + mClosedSession = false; + mIgnoreCrash = false; + + applyAnnotations(Arrays.asList(description.getTestClass().getAnnotations()), settings); + applyAnnotations(description.getAnnotations(), settings); + + final List<CallRecord> records = new ArrayList<>(); + final CallbackDelegates waitDelegates = new CallbackDelegates(); + final CallbackDelegates testDelegates = new CallbackDelegates(); + mCallRecords = records; + mWaitScopeDelegates = waitDelegates; + mTestScopeDelegates = testDelegates; + mLastWaitStart = 0; + mLastWaitEnd = 0; + + final InvocationHandler recorder = + new InvocationHandler() { + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) { + boolean ignore = false; + MethodCall call = null; + + if (Object.class.equals(method.getDeclaringClass())) { + switch (method.getName()) { + case "equals": + return proxy == args[0]; + case "toString": + return "Call Recorder"; + } + ignore = true; + } else if (mCallRecordHandler != null) { + ignore = mCallRecordHandler.handleCall(method, args); + } + + final boolean isDefaultDelegate = + DEFAULT_DELEGATES.contains(method.getDeclaringClass()); + final boolean isDefaultRuntimeDelegate = + DEFAULT_RUNTIME_DELEGATES.contains(method.getDeclaringClass()); + + if (!ignore) { + if (isDefaultDelegate) { + ThreadUtils.assertOnUiThread(); + } + + final GeckoSession session; + if (!isDefaultDelegate) { + session = null; + } else { + assertThat( + "Callback first argument must be session object", + args, + arrayWithSize(greaterThan(0))); + assertThat( + "Callback first argument must be session object", + args[0], + instanceOf(GeckoSession.class)); + session = (GeckoSession) args[0]; + } + + if ((sOnCrash.equals(method) || sOnKill.equals(method)) + && !mIgnoreCrash + && isUsingSession(session)) { + if (env.shouldShutdownOnCrash()) { + getRuntime().shutdown(); + } + + throw new ChildCrashedException("Child process crashed"); + } + + records.add(new CallRecord(session, method, args)); + + call = waitDelegates.prepareMethodCall(session, method); + if (call == null) { + call = testDelegates.prepareMethodCall(session, method); + } + + if (!isDefaultDelegate && !isDefaultRuntimeDelegate) { + assertThat("External delegate should be registered", call, notNullValue()); + } + } + + Object returnValue = null; + try { + mCurrentMethodCall = call; + if (call != null && call.target != null) { + returnValue = method.invoke(call.target, args); + } else { + returnValue = method.invoke(DEFAULT_IMPL, args); + } + } catch (final IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } finally { + mCurrentMethodCall = null; + } + + return returnValue; + } + }; + + final Set<Class<?>> delegates = new HashSet<>(); + delegates.addAll(DEFAULT_DELEGATES); + delegates.addAll(DEFAULT_RUNTIME_DELEGATES); + final Class<?>[] classes = delegates.toArray(new Class<?>[delegates.size()]); + mCallbackProxy = Proxy.newProxyInstance(GeckoSession.class.getClassLoader(), classes, recorder); + mAllDelegates = new HashSet<>(delegates); + + mMainSession = new GeckoSession(settings); + prepareSession(mMainSession); + prepareRuntime(getRuntime()); + + if (mDisplaySize != null) { + addDisplay(mMainSession, mDisplaySize.x, mDisplaySize.y); + } + + if (!mClosedSession) { + openSession(mMainSession); + UiThreadUtils.waitForCondition( + () -> RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL, + env.getDefaultTimeoutMillis()); + if (RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_OK) { + throw new RuntimeException("Could not register TestSupport, see logs for error."); + } + } + } + + protected void prepareRuntime(final GeckoRuntime runtime) { + UiThreadUtils.waitForCondition( + () -> RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL, + env.getDefaultTimeoutMillis()); + for (final Class<?> cls : DEFAULT_RUNTIME_DELEGATES) { + setRuntimeDelegate(cls, runtime, mNullDelegates.contains(cls) ? null : mCallbackProxy); + } + } + + protected void prepareSession(final GeckoSession session) { + UiThreadUtils.waitForCondition( + () -> RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL, + env.getDefaultTimeoutMillis()); + session + .getWebExtensionController() + .setMessageDelegate(RuntimeCreator.sTestSupportExtension, mMessageDelegate, "browser"); + for (final Class<?> cls : DEFAULT_DELEGATES) { + try { + setDelegate(cls, session, mNullDelegates.contains(cls) ? null : mCallbackProxy); + } catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Call open() on a session, and ensure it's ready for use by the test. In particular, remove any + * extra calls recorded as part of opening the session. + * + * @param session Session to open. + */ + public void openSession(final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + // We receive an initial about:blank load; don't expose that to the test. The initial + // load ends with the first onPageStop call, so ignore everything from the session + // until the first onPageStop call. + + try { + // We cannot detect initial page load without progress delegate. + assertThat( + "ProgressDelegate cannot be null-delegate when opening session", + GeckoSession.ProgressDelegate.class, + not(isIn(mNullDelegates))); + mCallRecordHandler = + (method, args) -> { + Log.e(LOGTAG, "method: " + method); + final boolean matching = + DEFAULT_DELEGATES.contains(method.getDeclaringClass()) && session.equals(args[0]); + if (matching && sOnPageStop.equals(method)) { + mCallRecordHandler = null; + } + return matching; + }; + + session.open(getRuntime()); + + UiThreadUtils.waitForCondition( + () -> mCallRecordHandler == null, env.getDefaultTimeoutMillis()); + } finally { + mCallRecordHandler = null; + } + } + + private void waitForOpenSession(final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + // We receive an initial about:blank load; don't expose that to the test. The initial + // load ends with the first onPageStop call, so ignore everything from the session + // until the first onPageStop call. + + try { + // We cannot detect initial page load without progress delegate. + assertThat( + "ProgressDelegate cannot be null-delegate when opening session", + GeckoSession.ProgressDelegate.class, + not(isIn(mNullDelegates))); + mCallRecordHandler = + (method, args) -> { + Log.e(LOGTAG, "method: " + method); + final boolean matching = + DEFAULT_DELEGATES.contains(method.getDeclaringClass()) && session.equals(args[0]); + if (matching && sOnPageStop.equals(method)) { + mCallRecordHandler = null; + } + return matching; + }; + + UiThreadUtils.waitForCondition( + () -> mCallRecordHandler == null, env.getDefaultTimeoutMillis()); + } finally { + mCallRecordHandler = null; + } + } + + /** Internal method to perform callback checks at the end of a test. */ + public void performTestEndCheck() { + mWaitScopeDelegates.clearAndAssert(); + mTestScopeDelegates.clearAndAssert(); + } + + protected void cleanupRuntime(final GeckoRuntime runtime) { + for (final Class<?> cls : DEFAULT_RUNTIME_DELEGATES) { + setRuntimeDelegate(cls, runtime, null); + } + } + + protected void cleanupSession(final GeckoSession session) { + if (session.isOpen()) { + session.close(); + } + releaseDisplay(session); + } + + protected boolean isUsingSession(final GeckoSession session) { + return session.equals(mMainSession) || mSubSessions.contains(session); + } + + protected void deleteCrashDumps() { + final File dumpDir = new File(getProfilePath(), "minidumps"); + for (final File dump : dumpDir.listFiles()) { + dump.delete(); + } + } + + protected void cleanupExtensions() throws Throwable { + final WebExtensionController controller = getRuntime().getWebExtensionController(); + final List<WebExtension> list = waitForResult(controller.list(), env.getDefaultTimeoutMillis()); + + boolean hasTestSupport = false; + // Uninstall any left-over extensions + for (final WebExtension extension : list) { + if (!extension.id.equals(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) { + waitForResult(controller.uninstall(extension), env.getDefaultTimeoutMillis()); + } else { + hasTestSupport = true; + } + } + + // If an extension was still installed, this test should fail. + // Note the test support extension is always kept for speed. + assertThat( + "A WebExtension was left installed during this test.", + list.size(), + equalTo(hasTestSupport ? 1 : 0)); + } + + protected void cleanupStatement() throws Throwable { + mWaitScopeDelegates.clear(); + mTestScopeDelegates.clear(); + + for (final GeckoSession session : mSubSessions) { + cleanupSession(session); + } + + cleanupRuntime(getRuntime()); + cleanupSession(mMainSession); + cleanupExtensions(); + + if (mIgnoreCrash) { + deleteCrashDumps(); + } + + mMainSession = null; + mCallbackProxy = null; + mAllDelegates = null; + mNullDelegates = null; + mCallRecords = null; + mWaitScopeDelegates = null; + mTestScopeDelegates = null; + mLastWaitStart = 0; + mLastWaitEnd = 0; + mTimeoutMillis = 0; + RuntimeCreator.setTelemetryDelegate(null); + } + + // These markers are used by runjunit.py to capture the logcat of a test + private static final String TEST_START_MARKER = "test_start 1f0befec-3ff2-40ff-89cf-b127eb38b1ec"; + private static final String TEST_END_MARKER = "test_end c5ee677f-bc83-49bd-9e28-2d35f3d0f059"; + + @Override + public Statement apply(final Statement base, final Description description) { + return new Statement() { + private TestServer mServer; + + private void initTest() { + try { + mServer.start(TEST_PORT); + + RuntimeCreator.setPortDelegate(mMessageDelegate); + getRuntime(); + + Log.e(LOGTAG, TEST_START_MARKER + " " + description); + Log.e(LOGTAG, "before prepareStatement " + description); + prepareStatement(description); + Log.e(LOGTAG, "after prepareStatement"); + } catch (final Throwable t) { + // Any error here is not related to a specific test + throw new TestHarnessException(t); + } + } + + @Override + public void evaluate() throws Throwable { + final AtomicReference<Throwable> exceptionRef = new AtomicReference<>(); + + mServer = new TestServer(InstrumentationRegistry.getInstrumentation().getTargetContext()); + + mInstrumentation.runOnMainSync( + () -> { + try { + initTest(); + base.evaluate(); + Log.e(LOGTAG, "after evaluate"); + performTestEndCheck(); + Log.e(LOGTAG, "after performTestEndCheck"); + } catch (final Throwable t) { + Log.e(LOGTAG, "Error", t); + exceptionRef.set(t); + } finally { + try { + mServer.stop(); + cleanupStatement(); + } catch (final Throwable t) { + exceptionRef.compareAndSet(null, t); + } + Log.e(LOGTAG, TEST_END_MARKER + " " + description); + } + }); + + final Throwable throwable = exceptionRef.get(); + if (throwable != null) { + throw throwable; + } + } + }; + } + + /** This simply sends an empty message to the web content and waits for a reply. */ + public void waitForRoundTrip(final GeckoSession session) { + waitForJS(session, "true"); + } + + /** + * Wait until a page load has finished on any session. A session must have started a page load + * since the last wait, or this method will wait indefinitely. + */ + public void waitForPageStop() { + waitForPageStop(/* session */ null); + } + + /** + * Wait until a page load has finished. The session must have started a page load since the last + * wait, or this method will wait indefinitely. + * + * @param session Session to wait on, or null to wait on any session. + */ + public void waitForPageStop(final GeckoSession session) { + waitForPageStops(session, /* count */ 1); + } + + /** + * Wait until a page load has finished on any session. A session must have started a page load + * since the last wait, or this method will wait indefinitely. + * + * @param count Number of page loads to wait for. + */ + public void waitForPageStops(final int count) { + waitForPageStops(/* session */ null, count); + } + + /** + * Wait until a page load has finished. The session must have started a page load since the last + * wait, or this method will wait indefinitely. + * + * @param session Session to wait on, or null to wait on any session. + * @param count Number of page loads to wait for. + */ + public void waitForPageStops(final GeckoSession session, final int count) { + final List<MethodCall> methodCalls = new ArrayList<>(1); + methodCalls.add( + new MethodCall(session, sOnPageStop, new CallRequirement(/* allowed */ true, count, null))); + + waitUntilCalled(session, GeckoSession.ProgressDelegate.class, methodCalls, null); + } + + /** + * Wait until the specified methods have been called on the specified callback interface for any + * session. If no methods are specified, wait until any method has been called. + * + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled( + final @NonNull KClass<?> callback, final @Nullable String... methods) { + waitUntilCalled(/* session */ null, callback, methods); + } + + /** + * Wait until the specified methods have been called on the specified callback interface. If no + * methods are specified, wait until any method has been called. + * + * @param session Session to wait on, or null to wait on any session. + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled( + final @Nullable GeckoSession session, + final @NonNull KClass<?> callback, + final @Nullable String... methods) { + waitUntilCalled(session, JvmClassMappingKt.getJavaClass(callback), methods); + } + + /** + * Wait until the specified methods have been called on the specified callback interface for any + * session. If no methods are specified, wait until any method has been called. + * + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled(final @NonNull Class<?> callback, final @Nullable String... methods) { + waitUntilCalled(/* session */ null, callback, methods); + } + + /** + * Wait until the specified methods have been called on the specified callback interface. If no + * methods are specified, wait until any method has been called. + * + * @param session Session to wait on, or null to wait on any session. + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled( + final @Nullable GeckoSession session, + final @NonNull Class<?> callback, + final @Nullable String... methods) { + final int length = (methods != null) ? methods.length : 0; + final Pattern[] patterns = new Pattern[length]; + for (int i = 0; i < length; i++) { + patterns[i] = Pattern.compile(methods[i]); + } + + final List<MethodCall> waitMethods = new ArrayList<>(); + boolean isSessionCallback = false; + + for (final Class<?> ifce : getCurrentDelegates()) { + if (!ifce.isAssignableFrom(callback)) { + continue; + } + for (final Method method : ifce.getMethods()) { + for (final Pattern pattern : patterns) { + if (!pattern.matcher(method.getName()).matches()) { + continue; + } + waitMethods.add(new MethodCall(session, method, new CallRequirement(true, -1, null))); + break; + } + } + isSessionCallback = true; + } + + assertThat( + "Delegate should be a GeckoSession delegate " + "or registered external delegate", + isSessionCallback, + equalTo(true)); + + waitUntilCalled(session, callback, waitMethods, null); + } + + /** + * Wait until the specified methods have been called on the specified object for any session, as + * specified by any {@link AssertCalled @AssertCalled} annotations. If no {@link + * AssertCalled @AssertCalled} annotations are found, wait until any method has been called. Only + * methods belonging to a GeckoSession callback are supported. + * + * @param callback Target callback object; must implement an interface under GeckoSession. + */ + public void waitUntilCalled(final @NonNull Object callback) { + waitUntilCalled(/* session */ null, callback); + } + + /** + * Wait until the specified methods have been called on the specified object, as specified by any + * {@link AssertCalled @AssertCalled} annotations. If no {@link AssertCalled @AssertCalled} + * annotations are found, wait until any method has been called. Only methods belonging to a + * GeckoSession callback are supported. + * + * @param session Session to wait on, or null to wait on any session. + * @param callback Target callback object; must implement an interface under GeckoSession. + */ + public void waitUntilCalled( + final @Nullable GeckoSession session, final @NonNull Object callback) { + if (callback instanceof Class<?>) { + waitUntilCalled(session, (Class<?>) callback, (String[]) null); + return; + } + + final List<MethodCall> methodCalls = new ArrayList<>(); + boolean isSessionCallback = false; + + for (final Class<?> ifce : getCurrentDelegates()) { + if (!ifce.isInstance(callback)) { + continue; + } + for (final Method method : ifce.getMethods()) { + final Method callbackMethod; + try { + callbackMethod = + callback.getClass().getMethod(method.getName(), method.getParameterTypes()); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + final AssertCalled ac = getAssertCalled(callbackMethod, callback); + methodCalls.add(new MethodCall(session, method, ac, /* target */ null)); + } + isSessionCallback = true; + } + + assertThat( + "Delegate should implement a GeckoSession, GeckoRuntime delegate " + + "or registered external delegate", + isSessionCallback, + equalTo(true)); + + waitUntilCalled(session, callback.getClass(), methodCalls, callback); + } + + /** + * * Implement this interface in {@link #waitUntilCalled} to allow waiting until this method + * returns true. E.g. for when the test needs to wait for a specific value on a delegate call. + */ + public interface ShouldContinue { + /** + * Whether the test should keep waiting or not. + * + * @return true if the test should keep waiting. + */ + default boolean shouldContinue() { + return false; + } + } + + private void waitUntilCalled( + final @Nullable GeckoSession session, + final @NonNull Class<?> delegate, + final @NonNull List<MethodCall> methodCalls, + final @Nullable Object callback) { + ThreadUtils.assertOnUiThread(); + + if (session != null && !session.equals(mMainSession)) { + assertThat("Session should be wrapped through wrapSession", session, isIn(mSubSessions)); + } + + // Make sure all handlers are set though #delegateUntilTestEnd or #delegateDuringNextWait, + // instead of through GeckoSession directly, so that we can still record calls even with + // custom handlers set. + for (final Class<?> ifce : DEFAULT_DELEGATES) { + final Object sessionDelegate; + try { + sessionDelegate = getDelegate(ifce, session == null ? mMainSession : session); + } catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } + if (mNullDelegates.contains(ifce)) { + // Null-delegates are initially null but are allowed to be any value. + continue; + } + assertThat( + ifce.getSimpleName() + + " callbacks should be " + + "accessed through GeckoSessionTestRule delegate methods", + sessionDelegate, + sameInstance(mCallbackProxy)); + } + + for (final Class<?> ifce : DEFAULT_RUNTIME_DELEGATES) { + final Object runtimeDelegate = getRuntimeDelegate(ifce, getRuntime()); + if (mNullDelegates.contains(ifce)) { + // Null-delegates are initially null but are allowed to be any value. + continue; + } + assertThat( + ifce.getSimpleName() + + " callbacks should be " + + "accessed through GeckoSessionTestRule delegate methods", + runtimeDelegate, + sameInstance(mCallbackProxy)); + } + + if (methodCalls.isEmpty()) { + // Waiting for any call on `delegate`; make sure it doesn't contain any null-delegates. + for (final Class<?> ifce : mNullDelegates) { + assertThat( + "Cannot wait on null-delegate callbacks", delegate, not(typeCompatibleWith(ifce))); + } + } else { + // Waiting for particular calls; make sure those calls aren't from a null-delegate. + for (final MethodCall call : methodCalls) { + assertThat( + "Cannot wait on null-delegate callbacks", + call.method.getDeclaringClass(), + not(isIn(mNullDelegates))); + } + } + + boolean calledAny = false; + int index = mLastWaitEnd; + final long startTime = SystemClock.uptimeMillis(); + + beforeWait(); + + ShouldContinue cont = new ShouldContinue() {}; + if (callback instanceof ShouldContinue) { + cont = (ShouldContinue) callback; + } + + List<MethodCall> pendingMethodCalls = + methodCalls.stream() + .filter( + mc -> mc.requirement != null && mc.requirement.count != 0 && mc.requirement.allowed) + .collect(Collectors.toList()); + + int order = 0; + while (!calledAny || !pendingMethodCalls.isEmpty() || cont.shouldContinue()) { + final int currentIndex = index; + + // Let's wait for more messages if we reached the end + UiThreadUtils.waitForCondition(() -> (currentIndex < mCallRecords.size()), mTimeoutMillis); + + if (SystemClock.uptimeMillis() - startTime > mTimeoutMillis) { + throw new UiThreadUtils.TimeoutException("Timed out after " + mTimeoutMillis + "ms"); + } + + final CallRecord record = mCallRecords.get(index); + final MethodCall recorded = record.methodCall; + + final boolean isDelegate = recorded.method.getDeclaringClass().isAssignableFrom(delegate); + + calledAny |= isDelegate; + index++; + + final int i = methodCalls.indexOf(recorded); + if (i < 0) { + continue; + } + + final MethodCall methodCall = methodCalls.get(i); + assertAllowMoreCalls(methodCall); + + methodCall.incrementCounter(); + assertOrder(methodCall, order); + order = Math.max(methodCall.getOrder(), order); + + if (methodCall.allowUnlimitedCalls() || !methodCall.allowMoreCalls()) { + pendingMethodCalls.remove(methodCall); + } + + if (isDelegate && callback != null) { + try { + mCurrentMethodCall = methodCall; + record.method.invoke(callback, record.args); + } catch (IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } finally { + mCurrentMethodCall = null; + } + } + } + + afterWait(index); + } + + protected void beforeWait() { + mLastWaitStart = mLastWaitEnd; + } + + protected void afterWait(final int endCallIndex) { + mLastWaitEnd = endCallIndex; + mWaitScopeDelegates.clearAndAssert(); + + // Register any test-delegates that were not registered due to wait-delegates + // having precedence. + for (final ExternalDelegate<?> delegate : mTestScopeDelegates.getExternalDelegates()) { + delegate.register(); + } + } + + /** + * Playback callbacks that were made on all sessions during the previous wait. For any methods + * annotated with {@link AssertCalled @AssertCalled}, assert that the callbacks satisfy the + * specified requirements. If no {@link AssertCalled @AssertCalled} annotations are found, assert + * any method has been called. Only methods belonging to a GeckoSession callback are supported. + * + * @param callback Target callback object; must implement one or more interfaces under + * GeckoSession. + */ + public void forCallbacksDuringWait(final @NonNull Object callback) { + forCallbacksDuringWait(/* session */ null, callback); + } + + /** + * Playback callbacks that were made during the previous wait. For any methods annotated with + * {@link AssertCalled @AssertCalled}, assert that the callbacks satisfy the specified + * requirements. If no {@link AssertCalled @AssertCalled} annotations are found, assert any method + * has been called. Only methods belonging to a GeckoSession callback are supported. + * + * @param session Target session object, or null to playback all sessions. + * @param callback Target callback object; must implement one or more interfaces under + * GeckoSession. + */ + public void forCallbacksDuringWait( + final @Nullable GeckoSession session, final @NonNull Object callback) { + final Method[] declaredMethods = callback.getClass().getDeclaredMethods(); + final List<MethodCall> methodCalls = new ArrayList<>(declaredMethods.length); + boolean assertingAnyCall = true; + Class<?> foundNullDelegate = null; + + for (final Class<?> ifce : mAllDelegates) { + if (!ifce.isInstance(callback)) { + continue; + } + if (mNullDelegates.contains(ifce)) { + foundNullDelegate = ifce; + } + for (final Method method : ifce.getMethods()) { + final Method callbackMethod; + try { + callbackMethod = + callback.getClass().getMethod(method.getName(), method.getParameterTypes()); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + final MethodCall call = + new MethodCall( + session, + callbackMethod, + getAssertCalled(callbackMethod, callback), + /* target */ null); + methodCalls.add(call); + + if (call.requirement != null) { + if (foundNullDelegate == ifce) { + fail("Cannot assert on null-delegate " + ifce.getSimpleName()); + } + assertingAnyCall = false; + } + } + } + + if (assertingAnyCall && foundNullDelegate != null) { + fail("Cannot assert on null-delegate " + foundNullDelegate.getSimpleName()); + } + + int order = 0; + boolean calledAny = false; + + for (int index = mLastWaitStart; index < mLastWaitEnd; index++) { + final CallRecord record = mCallRecords.get(index); + + if (!record.method.getDeclaringClass().isInstance(callback) + || (session != null + && DEFAULT_DELEGATES.contains(record.method.getDeclaringClass()) + && !session.equals(record.args[0]))) { + continue; + } + + final int i = methodCalls.indexOf(record.methodCall); + checkThat(record.method.getName() + " should be found", i, greaterThanOrEqualTo(0)); + + final MethodCall methodCall = methodCalls.get(i); + assertAllowMoreCalls(methodCall); + methodCall.incrementCounter(); + assertOrder(methodCall, order); + order = Math.max(methodCall.getOrder(), order); + + try { + mCurrentMethodCall = methodCall; + record.method.invoke(callback, record.args); + } catch (final IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } finally { + mCurrentMethodCall = null; + } + calledAny = true; + } + + for (final MethodCall methodCall : methodCalls) { + assertMatchesCount(methodCall); + if (methodCall.requirement != null) { + calledAny = true; + } + } + + checkThat( + "Should have called one of " + Arrays.toString(callback.getClass().getInterfaces()), + calledAny, + equalTo(true)); + } + + /** + * Get information about the current call. Only valid during a {@link #forCallbacksDuringWait}, + * {@link #delegateDuringNextWait}, or {@link #delegateUntilTestEnd} callback. + * + * @return Call information + */ + public @NonNull CallInfo getCurrentCall() { + assertThat("Should be in a method call", mCurrentMethodCall, notNullValue()); + return mCurrentMethodCall.getInfo(); + } + + /** + * Delegate implemented interfaces to the specified callback object for all sessions, for the rest + * of the test. Only GeckoSession callback interfaces are supported. Delegates for {@code + * delegateUntilTestEnd} can be temporarily overridden by delegates for {@link + * #delegateDuringNextWait}. + * + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateUntilTestEnd(final @NonNull Object callback) { + delegateUntilTestEnd(/* session */ null, callback); + } + + /** + * Delegate implemented interfaces to the specified callback object, for the rest of the test. + * Only GeckoSession callback interfaces are supported. Delegates for {@link + * #delegateUntilTestEnd} can be temporarily overridden by delegates for {@link + * #delegateDuringNextWait}. + * + * @param session Session to target, or null to target all sessions. + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateUntilTestEnd( + final @Nullable GeckoSession session, final @NonNull Object callback) { + mTestScopeDelegates.delegate(session, callback); + } + + /** + * Delegate implemented interfaces to the specified callback object for all sessions, during the + * next wait. Only GeckoSession callback interfaces are supported. Delegates for {@code + * delegateDuringNextWait} can temporarily take precedence over delegates for {@link + * #delegateUntilTestEnd}. + * + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateDuringNextWait(final @NonNull Object callback) { + delegateDuringNextWait(/* session */ null, callback); + } + + /** + * Delegate implemented interfaces to the specified callback object, during the next wait. Only + * GeckoSession callback interfaces are supported. Delegates for {@link #delegateDuringNextWait} + * can temporarily take precedence over delegates for {@link #delegateUntilTestEnd}. + * + * @param session Session to target, or null to target all sessions. + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateDuringNextWait( + final @Nullable GeckoSession session, final @NonNull Object callback) { + mWaitScopeDelegates.delegate(session, callback); + } + + /** + * Synthesize a tap event at the specified location using the main session. The session must have + * been created with a display. + * + * @param session Target session + * @param x X coordinate + * @param y Y coordinate + */ + public void synthesizeTap(final @NonNull GeckoSession session, final int x, final int y) { + final long downTime = SystemClock.uptimeMillis(); + final MotionEvent down = + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, x, y, 0); + session.getPanZoomController().onTouchEvent(down); + + final MotionEvent up = + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0); + session.getPanZoomController().onTouchEvent(up); + } + + /** + * Synthesize a mouse move event at the specified location using the main session. The session + * must have been created with a display. + * + * @param session Target session + * @param x X coordinate + * @param y Y coordinate + */ + public void synthesizeMouseMove(final @NonNull GeckoSession session, final int x, final int y) { + final MotionEvent.PointerProperties pointerProperty = new MotionEvent.PointerProperties(); + pointerProperty.id = 0; + pointerProperty.toolType = MotionEvent.TOOL_TYPE_MOUSE; + + final MotionEvent.PointerCoords pointerCoord = new MotionEvent.PointerCoords(); + pointerCoord.x = x; + pointerCoord.y = y; + + final MotionEvent.PointerProperties[] pointerProperties = + new MotionEvent.PointerProperties[] {pointerProperty}; + final MotionEvent.PointerCoords[] pointerCoords = + new MotionEvent.PointerCoords[] {pointerCoord}; + + final long moveTime = SystemClock.uptimeMillis(); + final MotionEvent moveEvent = + MotionEvent.obtain( + moveTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_HOVER_MOVE, + 1, + pointerProperties, + pointerCoords, + 0, + 0, + 1.0f, + 1.0f, + 0, + 0, + InputDevice.SOURCE_MOUSE, + 0); + session.getPanZoomController().onTouchEvent(moveEvent); + } + + /** + * Simulates a press to the Home button, causing the application to go to onPause. NB: Some time + * must elapse for the event to fully occur. + * + * @param context starting the Home intent + */ + public void simulatePressHome(Context context) { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_HOME); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + /** + * Simulates returningGeckoViewTestActivity to the foreground. Activity must already be in use. + * NB: Some time must elapse for the event to fully occur. + * + * @param context starting the intent + */ + public void requestActivityToForeground(Context context) { + Intent notificationIntent = new Intent(context, GeckoViewTestActivity.class); + notificationIntent.setAction(Intent.ACTION_MAIN); + notificationIntent.addCategory(Intent.CATEGORY_LAUNCHER); + notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(notificationIntent); + } + + /** + * Mock Location Provider can be used in testing for creating mock locations. NB: Likely also need + * to set test setting geo.provider.testing to false to prevent network geolocation from + * interfering when using. + */ + public class MockLocationProvider { + + private final LocationManager locationManager; + private final String mockProviderName; + private boolean isActiveTestProvider = false; + private double mockLatitude; + private double mockLongitude; + private float mockAccuracy = .000001f; + private boolean doContinuallyPost; + + @Nullable private ScheduledExecutorService executor; + + /** + * Mock Location Provider adds a test provider to the location manager and controls sending mock + * locations. Use @{@link #postLocation()} to post the location to the location manager. + * Use @{@link #removeMockLocationProvider()} to remove the location provider to clean-up the + * test harness. Default accuracy is .000001f. + * + * @param locationManager location manager to accept the locations + * @param mockProviderName location provider that will use this location + * @param mockLatitude initial latitude in degrees that @{@link #postLocation()} will use + * @param mockLongitude initial longitude in degrees that @{@link #postLocation()} will use + * @param doContinuallyPost when posting a location, continue to post every 3s to keep location + * current + */ + public MockLocationProvider( + LocationManager locationManager, + String mockProviderName, + double mockLatitude, + double mockLongitude, + boolean doContinuallyPost) { + this.locationManager = locationManager; + this.mockProviderName = mockProviderName; + this.mockLatitude = mockLatitude; + this.mockLongitude = mockLongitude; + this.doContinuallyPost = doContinuallyPost; + addMockLocationProvider(); + } + + /** Adds a mock location provider that can have locations manually set. */ + private void addMockLocationProvider() { + // Ensures that only one location provider with this name exists + removeMockLocationProvider(); + locationManager.addTestProvider( + mockProviderName, + false, + false, + false, + false, + false, + false, + false, + Criteria.POWER_LOW, + Criteria.ACCURACY_FINE); + locationManager.setTestProviderEnabled(mockProviderName, true); + isActiveTestProvider = true; + } + + /** + * Removes the location provider. Recommend calling when ending test to prevent the mock + * provider remaining as a test provider. + */ + public void removeMockLocationProvider() { + stopPostingLocation(); + try { + locationManager.removeTestProvider(mockProviderName); + } catch (Exception e) { + // Throws an exception if there is no provider with that name + } + isActiveTestProvider = false; + } + + /** + * Sets the mock location on MockLocationProvider, that will be used by @{@link #postLocation()} + * + * @param latitude latitude in degrees to mock + * @param longitude longitude in degrees to mock + */ + public void setMockLocation(double latitude, double longitude) { + mockLatitude = latitude; + mockLongitude = longitude; + } + + /** + * Sets the mock location on a MockLocationProvider, that will be used by @{@link + * #postLocation()} . Note, changing the accuracy can affect the importance of the mock provider + * compared to other location providers. + * + * @param latitude latitude in degrees to mock + * @param longitude longitude in degrees to mock + * @param accuracy horizontal accuracy in meters to mock + */ + public void setMockLocation(double latitude, double longitude, float accuracy) { + mockLatitude = latitude; + mockLongitude = longitude; + mockAccuracy = accuracy; + } + + /** + * When doContinuallyPost is set to true, @{@link #postLocation()} will post the location to the + * location manager every 3s. When set to false, @{@link #postLocation()} will only post the + * location once. Purpose is to prevent the location from becoming stale. + * + * @param doContinuallyPost setting for continually posting the location after calling @{@link + * #postLocation()} + */ + public void setDoContinuallyPost(boolean doContinuallyPost) { + this.doContinuallyPost = doContinuallyPost; + } + + /** + * Shutsdown and removes the executor created by @{@link #postLocation()} when @{@link + * #doContinuallyPost is true} to stop posting the location. + */ + public void stopPostingLocation() { + if (executor != null) { + executor.shutdown(); + executor = null; + } + } + + /** + * Posts the set location to the system location manager. If @{@link #doContinuallyPost} is + * true, the location will be posted every 3s by an executor, otherwise will post once. + */ + public void postLocation() { + if (!isActiveTestProvider) { + throw new IllegalStateException("The mock test provider is not active."); + } + + // Ensure the thread that was posting a location (if applicable) is stopped. + stopPostingLocation(); + + // Set Location + Location location = new Location(mockProviderName); + location.setAccuracy(mockAccuracy); + location.setLatitude(mockLatitude); + location.setLongitude(mockLongitude); + location.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); + location.setTime(System.currentTimeMillis()); + locationManager.setTestProviderLocation(mockProviderName, location); + Log.i( + LOGTAG, + mockProviderName + + " is posting location, lat: " + + mockLatitude + + " lon: " + + mockLongitude + + " acc: " + + mockAccuracy); + // Continually post location + if (doContinuallyPost) { + executor = Executors.newScheduledThreadPool(1); + executor.scheduleAtFixedRate( + new Runnable() { + @Override + public void run() { + location.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); + location.setTime(System.currentTimeMillis()); + locationManager.setTestProviderLocation(mockProviderName, location); + Log.i( + LOGTAG, + mockProviderName + + " is posting location, lat: " + + mockLatitude + + " lon: " + + mockLongitude + + " acc: " + + mockAccuracy); + } + }, + 0, + 3, + TimeUnit.SECONDS); + } + } + } + + Map<GeckoSession, WebExtension.Port> mPorts = new HashMap<>(); + + private class MessageDelegate implements WebExtension.MessageDelegate, WebExtension.PortDelegate { + @Override + public void onConnect(final @NonNull WebExtension.Port port) { + // Sometimes we get a new onConnect call _before_ onDisconnect, so we might + // have to detach the port here before we attach to a new one + detach(mPorts.remove(port.sender.session)); + attach(port); + } + + private void attach(WebExtension.Port port) { + mPorts.put(port.sender.session, port); + port.setDelegate(mMessageDelegate); + } + + private void detach(WebExtension.Port port) { + // If there are pending messages for this port we need to resolve them with an exception + // otherwise the test will wait for them indefinitely. + for (final String id : mPendingResponses.get(port)) { + final EvalJSResult result = new EvalJSResult(); + result.exception = new PortDisconnectException(); + mPendingMessages.put(id, result); + } + mPendingResponses.remove(port); + } + + @Override + public void onPortMessage( + @NonNull final Object message, @NonNull final WebExtension.Port port) { + final JSONObject response = (JSONObject) message; + + final String id; + try { + id = response.getString("id"); + final EvalJSResult result = new EvalJSResult(); + + final Object exception = response.get("exception"); + if (exception != JSONObject.NULL) { + result.exception = exception; + } + + final Object value = response.get("response"); + if (value != JSONObject.NULL) { + result.value = value; + } + + mPendingMessages.put(id, result); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onDisconnect(final @NonNull WebExtension.Port port) { + detach(port); + // Sometimes the onDisconnect call comes _after_ the new onConnect so we need to check + // here whether this port is still in use. + if (mPorts.get(port.sender.session) == port) { + mPorts.remove(port.sender.session); + } + } + + public class PortDisconnectException extends RuntimeException { + public PortDisconnectException() { + super( + "The port disconnected before a message could be received." + + "Usually this happens when the page navigates away while " + + "waiting for a message."); + } + } + } + + private MessageDelegate mMessageDelegate = new MessageDelegate(); + + private static class EvalJSResult { + Object value; + Object exception; + } + + Map<String, EvalJSResult> mPendingMessages = new HashMap<>(); + MultiMap<WebExtension.Port, String> mPendingResponses = new MultiMap<>(); + + public class ExtensionPromise { + private UUID mUuid; + private GeckoSession mSession; + + protected ExtensionPromise(final UUID uuid, final GeckoSession session, final String js) { + mUuid = uuid; + mSession = session; + evaluateJS(session, "this['" + uuid + "'] = " + js + "; true"); + } + + public Object getValue() { + return evaluateJS(mSession, "this['" + mUuid + "']"); + } + } + + public ExtensionPromise evaluatePromiseJS( + final @NonNull GeckoSession session, final @NonNull String js) { + return new ExtensionPromise(UUID.randomUUID(), session, js); + } + + public Object evaluateExtensionJS(final @NonNull String js) { + return webExtensionApiCall( + "Eval", + args -> { + args.put("code", js); + }); + } + + public Object evaluateJS(final @NonNull GeckoSession session, final @NonNull String js) { + // Let's make sure we have the port already + UiThreadUtils.waitForCondition(() -> mPorts.containsKey(session), mTimeoutMillis); + + final JSONObject message = new JSONObject(); + final String id = UUID.randomUUID().toString(); + try { + message.put("id", id); + message.put("eval", js); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + + final WebExtension.Port port = mPorts.get(session); + port.postMessage(message); + + return waitForMessage(port, id); + } + + public int getSessionPid(final @NonNull GeckoSession session) { + final Double dblPid = (Double) webExtensionApiCall(session, "GetPidForTab", null); + return dblPid.intValue(); + } + + public String getProfilePath() { + return (String) webExtensionApiCall("GetProfilePath", null); + } + + public int[] getAllSessionPids() { + final JSONArray jsonPids = (JSONArray) webExtensionApiCall("GetAllBrowserPids", null); + final int[] pids = new int[jsonPids.length()]; + for (int i = 0; i < jsonPids.length(); i++) { + try { + pids[i] = jsonPids.getInt(i); + } catch (final JSONException e) { + throw new RuntimeException(e); + } + } + return pids; + } + + public void killContentProcess(final int pid) { + webExtensionApiCall( + "KillContentProcess", + args -> { + args.put("pid", pid); + }); + } + + public boolean getActive(final @NonNull GeckoSession session) { + final Boolean isActive = (Boolean) webExtensionApiCall(session, "GetActive", null); + return isActive; + } + + public void triggerCookieBannerDetected(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "TriggerCookieBannerDetected", null); + } + + public void triggerCookieBannerHandled(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "TriggerCookieBannerHandled", null); + } + + private Object waitForMessage(final WebExtension.Port port, final String id) { + mPendingResponses.add(port, id); + UiThreadUtils.waitForCondition(() -> mPendingMessages.containsKey(id), mTimeoutMillis); + mPendingResponses.remove(port); + + final EvalJSResult result = mPendingMessages.get(id); + mPendingMessages.remove(id); + + if (result.exception != null) { + throw new RejectedPromiseException(result.exception); + } + + if (result.value == null) { + return null; + } + + Object value; + try { + value = new JSONTokener((String) result.value).nextValue(); + } catch (final JSONException ex) { + value = result.value; + } + + if (value instanceof Integer) { + return ((Integer) value).doubleValue(); + } + return value; + } + + /** + * Initialize and keep track of the specified session within the test rule. The session is + * automatically cleaned up at the end of the test. + * + * @param session Session to keep track of. + * @return Same session + */ + public GeckoSession wrapSession(final GeckoSession session) { + try { + mSubSessions.add(session); + prepareSession(session); + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } + return session; + } + + private GeckoSession createSession(final GeckoSessionSettings settings, final boolean open) { + final GeckoSession session = wrapSession(new GeckoSession(settings)); + if (open) { + openSession(session); + } + return session; + } + + /** + * Create a new, opened session using the main session settings. + * + * @return New session. + */ + public GeckoSession createOpenSession() { + return createSession(mMainSession.getSettings(), /* open */ true); + } + + /** + * Create a new, opened session using the specified settings. + * + * @param settings Settings for the new session. + * @return New session. + */ + public GeckoSession createOpenSession(final GeckoSessionSettings settings) { + return createSession(settings, /* open */ true); + } + + /** + * Create a new, closed session using the specified settings. + * + * @return New session. + */ + public GeckoSession createClosedSession() { + return createSession(mMainSession.getSettings(), /* open */ false); + } + + /** + * Create a new, closed session using the specified settings. + * + * @param settings Settings for the new session. + * @return New session. + */ + public GeckoSession createClosedSession(final GeckoSessionSettings settings) { + return createSession(settings, /* open */ false); + } + + /** + * Return a value from the given array indexed by the current call counter. Only valid during a + * {@link #forCallbacksDuringWait}, {@link #delegateDuringNextWait}, or {@link + * #delegateUntilTestEnd} callback. + * + * <p> + * + * <p>Asserts that {@code foo} is equal to {@code "bar"} during the first call and {@code "baz"} + * during the second call: + * + * <pre>{@code assertThat("Foo should match", foo, equalTo(forEachCall("bar", + * "baz")));}</pre> + * + * @param values Input array + * @return Value from input array indexed by the current call counter. + */ + @SafeVarargs + public final <T> T forEachCall(final T... values) { + assertThat("Should be in a method call", mCurrentMethodCall, notNullValue()); + return values[Math.min(mCurrentMethodCall.getCurrentCount(), values.length) - 1]; + } + + /** + * Evaluate a JavaScript expression and return the result, similar to {@link #evaluateJS}. In + * addition, treat the evaluation as a wait event, which will affect other calls such as {@link + * #forCallbacksDuringWait}. If the result is a Promise, wait on the Promise to settle and return + * or throw based on the outcome. + * + * @param session Session containing the target page. + * @param js JavaScript expression. + * @return Result of the expression or value of the resolved Promise. + * @see #evaluateJS + */ + public @Nullable Object waitForJS(final @NonNull GeckoSession session, final @NonNull String js) { + try { + beforeWait(); + return evaluateJS(session, js); + } finally { + afterWait(mCallRecords.size()); + } + } + + /** + * Get a list of Gecko prefs. Undefined prefs will return as null. + * + * @param prefs List of pref names. + * @return Pref values as a list of values. + */ + public JSONArray getPrefs(final @NonNull String... prefs) { + return (JSONArray) + webExtensionApiCall( + "GetPrefs", + args -> { + args.put("prefs", new JSONArray(Arrays.asList(prefs))); + }); + } + + /** + * Gets the color of a link for a given selector. + * + * @param selector Selector that matches the link + * @return String representing the color, e.g. rgb(0, 0, 255) + */ + public String getLinkColor(final GeckoSession session, final String selector) { + return (String) + webExtensionApiCall( + session, + "GetLinkColor", + args -> { + args.put("selector", selector); + }); + } + + public List<String> getRequestedLocales() { + try { + final JSONArray locales = (JSONArray) webExtensionApiCall("GetRequestedLocales", null); + final List<String> result = new ArrayList<>(); + + for (int i = 0; i < locales.length(); i++) { + result.add(locales.getString(i)); + } + + return result; + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + } + + /** + * Adds value to the given histogram. + * + * @param id the histogram id to increment. + * @param value to add to the histogram. + */ + public void addHistogram(final String id, final long value) { + webExtensionApiCall( + "AddHistogram", + args -> { + args.put("id", id); + args.put("value", value); + }); + } + + /** Revokes all SSL overrides */ + public void removeAllCertOverrides() { + webExtensionApiCall("RemoveAllCertOverrides", null); + } + + private interface SetArgs { + void setArgs(JSONObject object) throws JSONException; + } + + /** + * Sets value to the given scalar. + * + * @param id the scalar to be set. + * @param value the value to set. + */ + public <T> void setScalar(final String id, final T value) { + webExtensionApiCall( + "SetScalar", + args -> { + args.put("id", id); + args.put("value", value); + }); + } + + /** Invokes nsIDOMWindowUtils.setResolutionAndScaleTo. */ + public void setResolutionAndScaleTo(final GeckoSession session, final float resolution) { + webExtensionApiCall( + session, + "SetResolutionAndScaleTo", + args -> { + args.put("resolution", resolution); + }); + } + + /** Invokes nsIDOMWindowUtils.flushApzRepaints. */ + public void flushApzRepaints(final GeckoSession session) { + webExtensionApiCall(session, "FlushApzRepaints", null); + } + + /** Invokes a simplified version of promiseAllPaintsDone in paint_listener.js. */ + public void promiseAllPaintsDone(final GeckoSession session) { + webExtensionApiCall(session, "PromiseAllPaintsDone", null); + } + + /** Returns true if Gecko is using a GPU process. */ + public boolean usingGpuProcess() { + return (Boolean) webExtensionApiCall("UsingGpuProcess", null); + } + + /** Kills the GPU process cleanly with generating a crash report. */ + public void killGpuProcess() { + webExtensionApiCall("KillGpuProcess", null); + } + + /** Causes the GPU process to crash. */ + public void crashGpuProcess() { + webExtensionApiCall("CrashGpuProcess", null); + } + + /** Clears sites from the HSTS list. */ + public void clearHSTSState() { + webExtensionApiCall("ClearHSTSState", null); + } + + private Object webExtensionApiCall( + final @NonNull String apiName, final @NonNull SetArgs argsSetter) { + return webExtensionApiCall(null, apiName, argsSetter); + } + + private Object webExtensionApiCall( + final GeckoSession session, + final @NonNull String apiName, + final @NonNull SetArgs argsSetter) { + // Ensure background script is connected + UiThreadUtils.waitForCondition(() -> RuntimeCreator.backgroundPort() != null, mTimeoutMillis); + + if (session != null) { + // Ensure content script is connected + UiThreadUtils.waitForCondition(() -> mPorts.get(session) != null, mTimeoutMillis); + } + + final String id = UUID.randomUUID().toString(); + + final JSONObject message = new JSONObject(); + + try { + final JSONObject args = new JSONObject(); + if (argsSetter != null) { + argsSetter.setArgs(args); + } + + message.put("id", id); + message.put("type", apiName); + message.put("args", args); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + + final WebExtension.Port port; + if (session == null) { + port = RuntimeCreator.backgroundPort(); + } else { + // We post the message using session's port instead of the background port. By routing + // the message through the extension's content script, we are able to obtain and attach + // the session's WebExtension tab as a `tab` argument to the API. + port = mPorts.get(session); + } + + port.postMessage(message); + return waitForMessage(port, id); + } + + /** + * Set a list of Gecko prefs for the rest of the test. Prefs set in {@link + * #setPrefsDuringNextWait} can temporarily take precedence over prefs set in {@code + * setPrefsUntilTestEnd}. + * + * @param prefs Map of pref names to values. + * @see #setPrefsDuringNextWait + */ + public void setPrefsUntilTestEnd(final @NonNull Map<String, ?> prefs) { + mTestScopeDelegates.setPrefs(prefs); + } + + /** + * Set a list of Gecko prefs during the next wait. Prefs set in {@code setPrefsDuringNextWait} can + * temporarily take precedence over prefs set in {@link #setPrefsUntilTestEnd}. + * + * @param prefs Map of pref names to values. + * @see #setPrefsUntilTestEnd + */ + public void setPrefsDuringNextWait(final @NonNull Map<String, ?> prefs) { + mWaitScopeDelegates.setPrefs(prefs); + } + + /** + * Register an external, non-GeckoSession delegate, and start recording the delegate calls until + * the end of the test. The delegate can then be used with methods such as {@link + * #waitUntilCalled(Class, String...)} and {@link #forCallbacksDuringWait(Object)}. At the end of + * the test, the delegate is automatically unregistered. Delegates added by {@link + * #addExternalDelegateDuringNextWait} can temporarily take precedence over delegates added by + * {@code delegateUntilTestEnd}. + * + * @param delegate Delegate instance to register. + * @param register DelegateRegistrar instance that represents a function to register the delegate. + * @param unregister DelegateRegistrar instance that represents a function to unregister the + * delegate. + * @param impl Default delegate implementation. Its methods may be annotated with {@link + * AssertCalled} annotations to assert expected behavior. + * @see #addExternalDelegateDuringNextWait + */ + public <T> void addExternalDelegateUntilTestEnd( + @NonNull final Class<T> delegate, + @NonNull final DelegateRegistrar<T> register, + @NonNull final DelegateRegistrar<T> unregister, + @NonNull final T impl) { + final ExternalDelegate<T> externalDelegate = + mTestScopeDelegates.addExternalDelegate(delegate, register, unregister, impl); + + // Register if there is not a wait delegate to take precedence over this call. + if (!mWaitScopeDelegates.getExternalDelegates().contains(externalDelegate)) { + externalDelegate.register(); + } + } + + /** + * @see #addExternalDelegateUntilTestEnd(Class, DelegateRegistrar, DelegateRegistrar, Object) + */ + public <T> void addExternalDelegateUntilTestEnd( + @NonNull final KClass<T> delegate, + @NonNull final DelegateRegistrar<T> register, + @NonNull final DelegateRegistrar<T> unregister, + @NonNull final T impl) { + addExternalDelegateUntilTestEnd( + JvmClassMappingKt.getJavaClass(delegate), register, unregister, impl); + } + + /** + * Register an external, non-GeckoSession delegate, and start recording the delegate calls during + * the next wait. The delegate can then be used with methods such as {@link + * #waitUntilCalled(Class, String...)} and {@link #forCallbacksDuringWait(Object)}. After the next + * wait, the delegate is automatically unregistered. Delegates added by {@code + * addExternalDelegateDuringNextWait} can temporarily take precedence over delegates added by + * {@link #delegateUntilTestEnd}. + * + * @param delegate Delegate instance to register. + * @param register DelegateRegistrar instance that represents a function to register the delegate. + * @param unregister DelegateRegistrar instance that represents a function to unregister the + * delegate. + * @param impl Default delegate implementation. Its methods may be annotated with {@link + * AssertCalled} annotations to assert expected behavior. + * @see #addExternalDelegateDuringNextWait + */ + public <T> void addExternalDelegateDuringNextWait( + @NonNull final Class<T> delegate, + @NonNull final DelegateRegistrar<T> register, + @NonNull final DelegateRegistrar<T> unregister, + @NonNull final T impl) { + final ExternalDelegate<T> externalDelegate = + mWaitScopeDelegates.addExternalDelegate(delegate, register, unregister, impl); + + // Always register because this call always takes precedence, but make sure to unregister + // any test-delegates first. + final int index = mTestScopeDelegates.getExternalDelegates().indexOf(externalDelegate); + if (index >= 0) { + mTestScopeDelegates.getExternalDelegates().get(index).unregister(); + } + externalDelegate.register(); + } + + /** + * @see #addExternalDelegateDuringNextWait(Class, DelegateRegistrar, DelegateRegistrar, Object) + */ + public <T> void addExternalDelegateDuringNextWait( + @NonNull final KClass<T> delegate, + @NonNull final DelegateRegistrar<T> register, + @NonNull final DelegateRegistrar<T> unregister, + @NonNull final T impl) { + addExternalDelegateDuringNextWait( + JvmClassMappingKt.getJavaClass(delegate), register, unregister, impl); + } + + /** + * This waits for the given result and returns it's value. If the result failed with an exception, + * it is rethrown. + * + * @param result A {@link GeckoResult} instance. + * @param <T> The type of the value held by the {@link GeckoResult} + * @return The value of the completed {@link GeckoResult}. + */ + public <T> T waitForResult(@NonNull final GeckoResult<T> result) throws Throwable { + return waitForResult(result, mTimeoutMillis); + } + + /** + * This is similar to waitForResult with specific timeout. + * + * @param result A {@link GeckoResult} instance. + * @param timeout timeout in milliseconds + * @param <T> The type of the value held by the {@link GeckoResult} + * @return The value of the completed {@link GeckoResult}. + */ + private <T> T waitForResult(@NonNull final GeckoResult<T> result, final long timeout) + throws Throwable { + beforeWait(); + try { + return UiThreadUtils.waitForResult(result, timeout); + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } finally { + afterWait(mCallRecords.size()); + } + } +} |