Bug 1439410 - 4. Add JUnit4 rule for testing GeckoSession; r=snorp draft
authorJim Chen <nchen@mozilla.com>
Thu, 22 Feb 2018 18:39:12 -0500
changeset 758761 7d163ad832b5944baa2f113583d1179d706ab38c
parent 758760 ceda0c38a6e3bd32fb9d9a065df7babcc075bd5e
child 758762 f5178dd48faf02fe9b9ff27cb8468a3be80fb96e
push id100161
push userbmo:nchen@mozilla.com
push dateThu, 22 Feb 2018 23:39:58 +0000
reviewerssnorp
bugs1439410
milestone60.0a1
Bug 1439410 - 4. Add JUnit4 rule for testing GeckoSession; r=snorp Add a rule for setting up a GeckoSession for a JUnit4 test and letting the test wait for listener invocations and to verify listener behavior. MozReview-Commit-ID: 20ij409yY1Z
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java
@@ -0,0 +1,810 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.rule;
+
+import org.mozilla.gecko.GeckoSession;
+import org.mozilla.gecko.GeckoSessionSettings;
+import org.mozilla.geckoview.test.util.Callbacks;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import android.app.Instrumentation;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.rule.UiThreadTestRule;
+
+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.HashSet;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * 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 extends UiThreadTestRule {
+
+    private static final long DEFAULT_TIMEOUT_MILLIS = 10000;
+    public static final String APK_URI_PREFIX = "resource://android";
+
+    /**
+     * Specify the timeout for any of the wait methods, in milliseconds. Can be used
+     * on classes or methods.
+     */
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    @Retention(RetentionPolicy.RUNTIME)
+    public @interface TimeoutMillis {
+        long 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>
+     * Disable e10s for a particular test:
+     * <pre>
+     * &#64;Setting.List(&#64;Setting(key = Setting.Key.USE_MULTIPROCESS,
+     *                        value = "false"))
+     * &#64;Test public void test() { ... }
+     * </pre>
+     * <p>
+     * Use multiple settings:
+     * <pre>
+     * &#64;Setting.List({&#64;Setting(key = Setting.Key.USE_MULTIPROCESS,
+     *                         value = "false"),
+     *                &#64;Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+     *                         value = "true")})
+     * </pre>
+     */
+    @Target(ElementType.ANNOTATION_TYPE)
+    @Retention(RetentionPolicy.RUNTIME)
+    public @interface Setting {
+        enum Key {
+            CHROME_URI,
+            DISPLAY_MODE,
+            SCREEN_ID,
+            USE_MULTIPROCESS,
+            USE_PRIVATE_MODE,
+            USE_REMOTE_DEBUGGER,
+            USE_TRACKING_PROTECTION;
+
+            private final GeckoSessionSettings.Key<?> mKey;
+            private final Class<?> mType;
+
+            Key() {
+                final Field field;
+                try {
+                    field = GeckoSessionSettings.class.getField(name());
+                    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) {
+                if (boolean.class.equals(mType) || Boolean.class.equals(mType)) {
+                    settings.setBoolean((GeckoSessionSettings.Key<Boolean>) mKey,
+                            Boolean.valueOf(value));
+                } else if (int.class.equals(mType) || Integer.class.equals(mType)) {
+                    try {
+                        settings.setInt((GeckoSessionSettings.Key<Integer>) mKey,
+                                (Integer) GeckoSessionSettings.class.getField(value)
+                                        .get(null));
+                        return;
+                    } catch (final NoSuchFieldException | IllegalAccessException |
+                            ClassCastException e) {
+                    }
+                    settings.setInt((GeckoSessionSettings.Key<Integer>) mKey,
+                            Integer.valueOf(value));
+                } else if (String.class.equals(mType)) {
+                    settings.setString((GeckoSessionSettings.Key<String>) mKey, value);
+                } else {
+                    throw new IllegalArgumentException("Unsupported type: " +
+                            mType.getSimpleName());
+                }
+            }
+        }
+
+        @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,
+         *         or false if the method must not be called.
+         */
+        boolean value() default true;
+
+        /**
+         * @return If called, the number of calls called, or 0 to allow any number > 0.
+         */
+        int count() default 0;
+
+        /**
+         * @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;
+    }
+
+    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 Method method;
+        public final CallRequirement requirement;
+        private int currentCount;
+
+        /* package */ MethodCall(final Method method) {
+            this(method, (CallRequirement) null);
+        }
+
+        /* package */ MethodCall(final Method method,
+                                 final AssertCalled requirement) {
+            this(method, requirement != null ? new CallRequirement(
+                    requirement.value(), requirement.count(), requirement.order()) : null);
+        }
+
+        public MethodCall(final Method method,
+                          final CallRequirement requirement) {
+            this.method = method;
+            this.requirement = requirement;
+            currentCount = 0;
+        }
+
+        @Override
+        public boolean equals(final Object other) {
+            if (this == other) {
+                return true;
+            } else if (other instanceof MethodCall) {
+                return methodsEqual(method, ((MethodCall) other).method);
+            } else if (other instanceof Method) {
+                return methodsEqual(method, (Method) other);
+            }
+            return false;
+        }
+
+        /* 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) ? 0 :
+                   !requirement.allowed ? -1 : requirement.count;
+        }
+
+        /* package */ void incrementCounter() {
+            currentCount++;
+        }
+
+        /* package */ boolean allowCalls() {
+            return getCount() >= 0;
+        }
+
+        /* package */ boolean allowUnlimitedCalls() {
+            return getCount() == 0;
+        }
+
+        /* package */ boolean allowMoreCalls() {
+            final int count = getCount();
+            return count == 0 || count > currentCount;
+        }
+
+        /* package */ void assertAllowMoreCalls() {
+            final int count = getCount();
+            assertTrue(method.getName() + " should not be called", allowCalls());
+            assertTrue(method.getName() + " should be limited to " + count +
+                       " call" + (count > 1 ? "s" : ""), allowMoreCalls());
+        }
+
+        /* package */ void assertOrder(final int order) {
+            final int newOrder = getOrder();
+            if (newOrder != 0) {
+                assertTrue(method.getName() + " order number " + newOrder +
+                           " does not match expected order number " + order,
+                           newOrder >= order);
+            }
+        }
+
+        /* package */ void assertMatchesCount() {
+            if (requirement == null) {
+                return;
+            }
+            final int count = getCount();
+            if (count < 0) {
+                assertEquals(method.getName() + " should not be called",
+                             0, currentCount);
+            } else if (count == 0) {
+                assertThat(method.getName() + " should be called", currentCount,
+                           is(greaterThan(0)));
+            } else {
+                assertEquals(method.getName() + " should be called " + count + " time" +
+                             (count == 1 ? "" : "s"), 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 Method method, final Object[] args) {
+            this.method = method;
+            this.methodCall = new MethodCall(method);
+            this.args = args;
+        }
+    }
+
+    /* 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 void addCallbackClasses(final List<Class<?>> list, final Class<?> ifce) {
+        if (!Callbacks.class.equals(ifce.getDeclaringClass())) {
+            list.add(ifce);
+            return;
+        }
+        final Class<?>[] superIfces = ifce.getInterfaces();
+        for (final Class<?> superIfce : superIfces) {
+            addCallbackClasses(list, superIfce);
+        }
+    }
+
+    private static Class<?>[] getCallbackClasses() {
+        final Class<?>[] ifces = Callbacks.class.getDeclaredClasses();
+        final List<Class<?>> list = new ArrayList<>(ifces.length);
+
+        for (final Class<?> ifce : ifces) {
+            addCallbackClasses(list, ifce);
+        }
+
+        final HashSet<Class<?>> set = new HashSet<>(list);
+        return set.toArray(new Class<?>[set.size()]);
+    }
+
+    private static final List<Class<?>> CALLBACK_CLASSES = Arrays.asList(getCallbackClasses());
+
+    protected final Instrumentation mInstrumentation =
+            InstrumentationRegistry.getInstrumentation();
+    protected final GeckoSessionSettings mDefaultSettings;
+
+    protected GeckoSession mSession;
+    protected Object mCallbackProxy;
+    protected List<CallRecord> mCallRecords;
+    protected int mLastWaitStart;
+    protected int mLastWaitEnd;
+    protected MethodCall mCurrentMethodCall;
+    protected long mTimeoutMillis = DEFAULT_TIMEOUT_MILLIS;
+
+    public GeckoSessionTestRule() {
+        mDefaultSettings = new GeckoSessionSettings();
+    }
+
+    /**
+     * Get the session set up for the current test.
+     *
+     * @return GeckoSession object.
+     */
+    public @NonNull GeckoSession getSession() {
+        return mSession;
+    }
+
+    protected static Method getCallbackSetter(final @NonNull Class<?> cls)
+            throws NoSuchMethodException {
+        return GeckoSession.class.getMethod("set" + cls.getSimpleName(), cls);
+    }
+
+    protected static Method getCallbackGetter(final @NonNull Class<?> cls)
+            throws NoSuchMethodException {
+        return GeckoSession.class.getMethod("get" + cls.getSimpleName());
+    }
+
+    protected void applyAnnotations(final Collection<Annotation> annotations,
+                                    final GeckoSessionSettings settings) {
+        for (final Annotation annotation : annotations) {
+            if (TimeoutMillis.class.equals(annotation.annotationType())) {
+                mTimeoutMillis = Math.max(((TimeoutMillis) annotation).value(), 100);
+            } else if (Setting.List.class.equals(annotation.annotationType())) {
+                for (final Setting setting : ((Setting.List) annotation).value()) {
+                    setting.key().set(settings, setting.value());
+                }
+            }
+        }
+    }
+
+    protected void prepareSession(final Description description) throws Throwable {
+        final GeckoSessionSettings settings = new GeckoSessionSettings(mDefaultSettings);
+
+        applyAnnotations(Arrays.asList(description.getTestClass().getAnnotations()), settings);
+        applyAnnotations(description.getAnnotations(), settings);
+
+        final List<CallRecord> records = new ArrayList<>();
+        mCallRecords = records;
+        mLastWaitStart = 0;
+        mLastWaitEnd = 0;
+
+        final InvocationHandler recorder = new InvocationHandler() {
+            @Override
+            public Object invoke(final Object proxy, final Method method,
+                                 final Object[] args) {
+                assertEquals("Callbacks must be on UI thread",
+                             Looper.getMainLooper(), Looper.myLooper());
+
+                records.add(new CallRecord(method, args));
+
+                try {
+                    return method.invoke(Callbacks.Default.INSTANCE, args);
+                } catch (final IllegalAccessException | InvocationTargetException e) {
+                    throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+                }
+            }
+        };
+
+        final Class<?>[] classes = CALLBACK_CLASSES.toArray(new Class<?>[CALLBACK_CLASSES.size()]);
+        mCallbackProxy = Proxy.newProxyInstance(GeckoSession.class.getClassLoader(),
+                                                classes, recorder);
+
+        mSession = new GeckoSession(settings);
+
+        for (final Class<?> cls : CALLBACK_CLASSES) {
+            if (cls != null) {
+                getCallbackSetter(cls).invoke(mSession, mCallbackProxy);
+            }
+        }
+
+        mSession.openWindow(mInstrumentation.getTargetContext());
+
+        if (settings.getBoolean(GeckoSessionSettings.USE_MULTIPROCESS)) {
+            // Under e10s, we receive an initial about:blank load; don't expose that to the test.
+            waitForPageStop();
+        }
+    }
+
+    protected void cleanupSession() throws Throwable {
+        if (mSession.isOpen()) {
+            mSession.closeWindow();
+        }
+        mSession = null;
+        mCallbackProxy = null;
+        mCallRecords = null;
+        mLastWaitStart = 0;
+        mLastWaitEnd = 0;
+        mTimeoutMillis = DEFAULT_TIMEOUT_MILLIS;
+    }
+
+    @Override
+    public Statement apply(final Statement base, final Description description) {
+        return super.apply(new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+                try {
+                    prepareSession(description);
+                    base.evaluate();
+                } finally {
+                    cleanupSession();
+                }
+            }
+        }, description);
+    }
+
+    @Override
+    protected boolean shouldRunOnUiThread(final Description description) {
+        return true;
+    }
+
+    /**
+     * Loop the current thread until the message queue is idle. If loop is already idle and
+     * timeout is not specified, return immediately. If loop is already idle and timeout is
+     * specified, wait for a message to arrive first; an exception is thrown if timeout
+     * expires during the wait.
+     *
+     * @param timeout Wait timeout in milliseconds or 0 to not wait.
+     */
+    protected static void loopUntilIdle(final long timeout) {
+        // Adapted from GeckoThread.pumpMessageLoop.
+        final Looper looper = Looper.myLooper();
+        assertNotNull("Looper must exist", looper);
+
+        final MessageQueue queue = looper.getQueue();
+        final Handler handler = new Handler(looper);
+        final MessageQueue.IdleHandler idleHandler = new MessageQueue.IdleHandler() {
+            @Override
+            public boolean queueIdle() {
+                final Message msg = Message.obtain(handler);
+                msg.obj = handler;
+                handler.sendMessageAtFrontOfQueue(msg);
+                return false; // Remove this idle handler.
+            }
+        };
+        final Runnable timeoutRunnable = new Runnable() {
+            @Override
+            public void run() {
+                fail("Timed out after " + timeout + "ms");
+            }
+        };
+
+        if (timeout > 0) {
+            handler.postDelayed(timeoutRunnable, timeout);
+        } else {
+            queue.addIdleHandler(idleHandler);
+        }
+
+        final Method getNextMessage;
+        try {
+            getNextMessage = queue.getClass().getDeclaredMethod("next");
+        } catch (final NoSuchMethodException e) {
+            throw new RuntimeException(e);
+        }
+        getNextMessage.setAccessible(true);
+
+        while (true) {
+            final Message msg;
+            try {
+                msg = (Message) getNextMessage.invoke(queue);
+            } catch (final IllegalAccessException | InvocationTargetException e) {
+                throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+            }
+            if (msg.getTarget() == handler && msg.obj == handler) {
+                // Our idle signal.
+                break;
+            } else if (msg.getTarget() == null) {
+                looper.quit();
+                break;
+            }
+            msg.getTarget().dispatchMessage(msg);
+
+            if (timeout > 0) {
+                handler.removeCallbacks(timeoutRunnable);
+                queue.addIdleHandler(idleHandler);
+            }
+        }
+    }
+
+    /**
+     * 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.
+     */
+    public void waitForPageStop() {
+        waitForPageStops(/* count */ 1);
+    }
+
+    /**
+     * 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 count Number of page loads to wait for.
+     */
+    public void waitForPageStops(final int count) {
+        final Method onPageStop;
+        try {
+            onPageStop = GeckoSession.ProgressListener.class.getMethod(
+                    "onPageStop", GeckoSession.class, boolean.class);
+        } catch (final NoSuchMethodException e) {
+            throw new RuntimeException(e);
+        }
+
+        final List<MethodCall> methodCalls = new ArrayList<>(1);
+        methodCalls.add(new MethodCall(onPageStop,
+                new CallRequirement(/* allowed */ true, count, null)));
+
+        waitUntilCalled(GeckoSession.ProgressListener.class, methodCalls);
+    }
+
+    /**
+     * 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 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) {
+        assertTrue("Class should be a GeckoSession interface",
+                   CALLBACK_CLASSES.contains(callback));
+
+        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<>();
+
+        for (final Method method : callback.getDeclaredMethods()) {
+            for (final Pattern pattern : patterns) {
+                if (pattern.matcher(method.getName()).matches()) {
+                    waitMethods.add(new MethodCall(method));
+                }
+            }
+        }
+
+        waitUntilCalled(callback, waitMethods);
+    }
+
+    /**
+     * 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 callback Target callback object; must implement an interface under GeckoSession.
+     */
+    public void waitUntilCalled(final @NonNull Object callback) {
+        if (callback instanceof Class<?>) {
+            waitUntilCalled((Class<?>) callback, (String[]) null);
+            return;
+        }
+
+        final List<MethodCall> methodCalls = new ArrayList<>();
+        for (final Class<?> ifce : CALLBACK_CLASSES) {
+            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);
+                if (ac != null && ac.value()) {
+                    methodCalls.add(new MethodCall(callbackMethod, ac));
+                }
+            }
+        }
+
+        waitUntilCalled(callback.getClass(), methodCalls);
+        forCallbacksDuringWait(callback);
+    }
+
+    protected void waitUntilCalled(final @NonNull Class<?> listener,
+                                   final @NonNull List<MethodCall> methodCalls) {
+        // 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 : CALLBACK_CLASSES) {
+            try {
+                assertSame("Callbacks should be set through" +
+                           " GeckoSessionTestRule delegate methods",
+                           mCallbackProxy, getCallbackGetter(ifce).invoke(mSession));
+            } catch (final NoSuchMethodException | IllegalAccessException |
+                           InvocationTargetException e) {
+                throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+            }
+        }
+
+        boolean calledAny = false;
+        int index = mLastWaitStart = mLastWaitEnd;
+
+        while (!calledAny || !methodCalls.isEmpty()) {
+            while (index >= mCallRecords.size()) {
+                loopUntilIdle(mTimeoutMillis);
+            }
+
+            final MethodCall recorded = mCallRecords.get(index).methodCall;
+            calledAny |= recorded.method.getDeclaringClass().isAssignableFrom(listener);
+            index++;
+
+            final int i = methodCalls.indexOf(recorded);
+            if (i < 0) {
+                continue;
+            }
+
+            final MethodCall methodCall = methodCalls.get(i);
+            methodCall.incrementCounter();
+            if (methodCall.allowUnlimitedCalls() || !methodCall.allowMoreCalls()) {
+                methodCalls.remove(i);
+            }
+        }
+
+        mLastWaitEnd = index;
+    }
+
+    /**
+     * 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 callback Target callback object; must implement one or more interfaces
+     *                 under GeckoSession.
+     */
+    public void forCallbacksDuringWait(final @NonNull Object callback) {
+        final Method[] declaredMethods = callback.getClass().getDeclaredMethods();
+        final List<MethodCall> methodCalls = new ArrayList<>(declaredMethods.length);
+        for (final Class<?> ifce : CALLBACK_CLASSES) {
+            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);
+                }
+                methodCalls.add(new MethodCall(
+                        callbackMethod, getAssertCalled(callbackMethod, callback)));
+            }
+        }
+
+        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)) {
+                continue;
+            }
+
+            final int i = methodCalls.indexOf(record.methodCall);
+            assertTrue(record.method.getName() + " should be found", i >= 0);
+
+            final MethodCall methodCall = methodCalls.get(i);
+            methodCall.assertAllowMoreCalls();
+            methodCall.incrementCounter();
+            methodCall.assertOrder(order);
+            order = Math.max(methodCall.getOrder(), order);
+
+            try {
+                mCurrentMethodCall = methodCall;
+                record.method.invoke(callback, record.args);
+            } catch (final IllegalAccessException | InvocationTargetException e) {
+                throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+            } finally {
+                mCurrentMethodCall = null;
+            }
+            calledAny = true;
+        }
+
+        for (final MethodCall methodCall : methodCalls) {
+            methodCall.assertMatchesCount();
+            if (methodCall.requirement != null) {
+                calledAny = true;
+            }
+        }
+
+        assertTrue("Should have called one of " +
+                   Arrays.toString(callback.getClass().getInterfaces()), calledAny);
+    }
+
+    /**
+     * Get information about the current call. Only valid during a {@link #forCallbacksDuringWait}
+     * callback.
+     *
+     * @return Call information
+     */
+    public @NonNull CallInfo getCurrentCall() {
+        assertNotNull("Should be in a method call", mCurrentMethodCall);
+        return mCurrentMethodCall.getInfo();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.util
+
+import org.mozilla.gecko.GeckoSession
+import org.mozilla.gecko.util.GeckoBundle
+
+class Callbacks private constructor() {
+    object Default : All {
+    }
+
+    interface All : ContentListener, NavigationListener, PermissionDelegate, ProgressListener,
+                    PromptDelegate, ScrollListener, TrackingProtectionDelegate {
+    }
+
+    interface ContentListener : GeckoSession.ContentListener {
+        override fun onTitleChange(session: GeckoSession, title: String) {
+        }
+
+        override fun onFocusRequest(session: GeckoSession) {
+        }
+
+        override fun onCloseRequest(session: GeckoSession) {
+        }
+
+        override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
+        }
+
+        override fun onContextMenu(session: GeckoSession, screenX: Int, screenY: Int, uri: String, elementSrc: String) {
+        }
+    }
+
+    interface NavigationListener : GeckoSession.NavigationListener {
+        override fun onLocationChange(session: GeckoSession, url: String) {
+        }
+
+        override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+        }
+
+        override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+        }
+
+        override fun onLoadUri(session: GeckoSession, uri: String, where: GeckoSession.NavigationListener.TargetWindow): Boolean {
+            return false;
+        }
+
+        override fun onNewSession(session: GeckoSession, uri: String, response: GeckoSession.Response<GeckoSession>) {
+            response.respond(null)
+        }
+    }
+
+    interface PermissionDelegate : GeckoSession.PermissionDelegate {
+        override fun requestAndroidPermissions(session: GeckoSession, permissions: Array<out String>, callback: GeckoSession.PermissionDelegate.Callback) {
+            callback.reject()
+        }
+
+        override fun requestContentPermission(session: GeckoSession, uri: String, type: String, access: String, callback: GeckoSession.PermissionDelegate.Callback) {
+            callback.reject()
+        }
+
+        override fun requestMediaPermission(session: GeckoSession, uri: String, video: Array<out GeckoSession.PermissionDelegate.MediaSource>, audio: Array<out GeckoSession.PermissionDelegate.MediaSource>, callback: GeckoSession.PermissionDelegate.MediaCallback) {
+            callback.reject()
+        }
+    }
+
+    interface ProgressListener : GeckoSession.ProgressListener {
+        override fun onPageStart(session: GeckoSession, url: String) {
+        }
+
+        override fun onPageStop(session: GeckoSession, success: Boolean) {
+        }
+
+        override fun onSecurityChange(session: GeckoSession, securityInfo: GeckoSession.ProgressListener.SecurityInformation) {
+        }
+    }
+
+    interface PromptDelegate : GeckoSession.PromptDelegate {
+        override fun alert(session: GeckoSession, title: String, msg: String, callback: GeckoSession.PromptDelegate.AlertCallback) {
+            callback.dismiss()
+        }
+
+        override fun promptForButton(session: GeckoSession, title: String, msg: String, btnMsg: Array<out String>, callback: GeckoSession.PromptDelegate.ButtonCallback) {
+            callback.dismiss()
+        }
+
+        override fun promptForText(session: GeckoSession, title: String, msg: String, value: String, callback: GeckoSession.PromptDelegate.TextCallback) {
+            callback.dismiss()
+        }
+
+        override fun promptForAuth(session: GeckoSession, title: String, msg: String, options: GeckoSession.PromptDelegate.AuthenticationOptions, callback: GeckoSession.PromptDelegate.AuthCallback) {
+            callback.dismiss()
+        }
+
+        override fun promptForChoice(session: GeckoSession, title: String, msg: String, type: Int, choices: Array<out GeckoSession.PromptDelegate.Choice>, callback: GeckoSession.PromptDelegate.ChoiceCallback) {
+            callback.dismiss()
+        }
+
+        override fun promptForColor(session: GeckoSession, title: String, value: String, callback: GeckoSession.PromptDelegate.TextCallback) {
+            callback.dismiss()
+        }
+
+        override fun promptForDateTime(session: GeckoSession, title: String, type: Int, value: String, min: String, max: String, callback: GeckoSession.PromptDelegate.TextCallback) {
+            callback.dismiss()
+        }
+
+        override fun promptForFile(session: GeckoSession, title: String, type: Int, mimeTypes: Array<out String>, callback: GeckoSession.PromptDelegate.FileCallback) {
+            callback.dismiss()
+        }
+    }
+
+    interface ScrollListener : GeckoSession.ScrollListener {
+        override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+        }
+    }
+
+    interface TrackingProtectionDelegate : GeckoSession.TrackingProtectionDelegate {
+        override fun onTrackerBlocked(session: GeckoSession, uri: String, categories: Int) {
+        }
+    }
+}