Bug 1463576 - 3. Add external delegate support in GeckoSessionTestRule; r?snorp draft
authorJim Chen <nchen@mozilla.com>
Fri, 01 Jun 2018 13:39:20 -0400
changeset 802940 ef46c0d2724f9f0053c9cbc44ef578bb943adeb4
parent 802939 be5910bda78f73658152274c4831ae681bf8bce5
child 802941 0e035e93f52e18d2be4539269dad4fa57620b4bf
push id112001
push userbmo:nchen@mozilla.com
push dateFri, 01 Jun 2018 17:40:20 +0000
reviewerssnorp
bugs1463576
milestone62.0a1
Bug 1463576 - 3. Add external delegate support in GeckoSessionTestRule; r?snorp Add support for using non-GeckoSession, external delegates with GeckoSessionTestRule, so we can wait on the clipboard to change during a test, for example. MozReview-Commit-ID: D8sfJ8gMLaY
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
@@ -1113,17 +1113,17 @@ class GeckoSessionTestRuleTest : BaseSes
         val newSession = sessionRule.createOpenSession()
         newSession.loadTestPath(HELLO_HTML_PATH)
         newSession.waitForPageStop()
 
         assertThat("Callback count should be correct", counter, equalTo(1))
     }
 
     @Test fun delegateDuringNextWait_hasPrecedenceWithSpecificSession() {
-        var newSession = sessionRule.createOpenSession()
+        val newSession = sessionRule.createOpenSession()
         var counter = 0
 
         newSession.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
             @AssertCalled(count = 1)
             override fun onPageStop(session: GeckoSession, success: Boolean) {
                 counter++
             }
         })
@@ -1138,17 +1138,17 @@ class GeckoSessionTestRuleTest : BaseSes
         newSession.loadTestPath(HELLO_HTML_PATH)
         sessionRule.session.loadTestPath(HELLO_HTML_PATH)
         sessionRule.waitForPageStops(2)
 
         assertThat("Callback count should be correct", counter, equalTo(1))
     }
 
     @Test fun delegateDuringNextWait_specificSessionOverridesAll() {
-        var newSession = sessionRule.createOpenSession()
+        val newSession = sessionRule.createOpenSession()
         var counter = 0
 
         newSession.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
             @AssertCalled(count = 1)
             override fun onPageStop(session: GeckoSession, success: Boolean) {
                 counter++
             }
         })
@@ -1539,9 +1539,96 @@ class GeckoSessionTestRuleTest : BaseSes
     }
 
     @WithDevToolsAPI
     @Test fun forceGarbageCollection() {
         sessionRule.forceGarbageCollection()
         sessionRule.session.reload()
         sessionRule.session.waitForPageStop()
     }
+
+    private interface TestDelegate {
+        fun onDelegate(foo: String, bar: String): Int
+    }
+
+    @Test fun addExternalDelegateUntilTestEnd() {
+        lateinit var delegate: TestDelegate
+
+        sessionRule.addExternalDelegateUntilTestEnd(
+                TestDelegate::class, { newDelegate -> delegate = newDelegate }, { },
+                object : TestDelegate {
+            @AssertCalled(count = 1)
+            override fun onDelegate(foo: String, bar: String): Int {
+                assertThat("First argument should be correct", foo, equalTo("foo"))
+                assertThat("Second argument should be correct", bar, equalTo("bar"))
+                return 42
+            }
+        })
+
+        assertThat("Delegate should be registered", delegate, notNullValue())
+        assertThat("Delegate return value should be correct",
+                   delegate.onDelegate("foo", "bar"), equalTo(42))
+        sessionRule.performTestEndCheck()
+    }
+
+    @Test(expected = AssertionError::class)
+    fun addExternalDelegateUntilTestEnd_throwOnNotCalled() {
+        sessionRule.addExternalDelegateUntilTestEnd(TestDelegate::class, { }, { },
+                                                    object : TestDelegate {
+            @AssertCalled(count = 1)
+            override fun onDelegate(foo: String, bar: String): Int {
+                return 42
+            }
+        })
+        sessionRule.performTestEndCheck()
+    }
+
+    @Test fun addExternalDelegateDuringNextWait() {
+        var delegate: Runnable? = null
+
+        sessionRule.addExternalDelegateDuringNextWait(Runnable::class,
+                                                      { newDelegate -> delegate = newDelegate },
+                                                      { delegate = null }, Runnable { })
+
+        assertThat("Delegate should be registered", delegate, notNullValue())
+        delegate?.run()
+
+        mainSession.reload()
+        mainSession.waitForPageStop()
+        mainSession.forCallbacksDuringWait(Runnable @AssertCalled(count = 1) {})
+
+        assertThat("Delegate should be unregistered after wait", delegate, nullValue())
+    }
+
+    @Test fun addExternalDelegateDuringNextWait_hasPrecedence() {
+        var delegate: TestDelegate? = null
+        val register = { newDelegate: TestDelegate -> delegate = newDelegate }
+        val unregister = { _: TestDelegate -> delegate = null }
+
+        sessionRule.addExternalDelegateDuringNextWait(TestDelegate::class, register, unregister,
+                                                      object : TestDelegate {
+            @AssertCalled(count = 1)
+            override fun onDelegate(foo: String, bar: String): Int {
+                return 24
+            }
+        })
+
+        sessionRule.addExternalDelegateUntilTestEnd(TestDelegate::class, register, unregister,
+                                                    object : TestDelegate {
+            @AssertCalled(count = 1)
+            override fun onDelegate(foo: String, bar: String): Int {
+                return 42
+            }
+        })
+
+        assertThat("Wait delegate should be registered", delegate, notNullValue())
+        assertThat("Wait delegate return value should be correct",
+                   delegate?.onDelegate("", ""), equalTo(24))
+
+        mainSession.reload()
+        mainSession.waitForPageStop()
+
+        assertThat("Test delegate should still be registered", delegate, notNullValue())
+        assertThat("Test delegate return value should be correct",
+                   delegate?.onDelegate("", ""), equalTo(42))
+        sessionRule.performTestEndCheck()
+    }
 }
--- 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
@@ -302,16 +302,23 @@ public class GeckoSessionTestRule extend
         /**
          * @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;
+    }
+
     public static class TimeoutException extends RuntimeException {
         public TimeoutException(final String detailMessage) {
             super(detailMessage);
         }
     }
 
     public static class RejectedPromiseException extends RuntimeException {
         private final Object mReason;
@@ -555,53 +562,139 @@ public class GeckoSessionTestRule extend
             return BuildConfig.DEBUG_BUILD;
         }
 
         public String getCPUArch() {
             return BuildConfig.ANDROID_CPU_ARCH;
         }
     }
 
+    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(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 String mOldPrefs;
 
         public void delegate(final @Nullable GeckoSession session,
                              final @NonNull Object callback) {
-            for (final Class<?> ifce : CALLBACK_CLASSES) {
+            for (final Class<?> ifce : DEFAULT_DELEGATES) {
                 if (!ifce.isInstance(callback)) {
                     continue;
                 }
                 assertThat("Cannot delegate null-delegate callbacks",
                            ifce, not(isIn(mNullDelegates)));
+                addDelegatesForInterface(session, callback, 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);
+        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) {
             final String existingPrefs;
             if (mOldPrefs == null) {
                 existingPrefs = "{}";
             } else {
                 existingPrefs = String.format("JSON.parse(%s)", JSONObject.quote(mOldPrefs));
             }
@@ -661,16 +754,20 @@ public class GeckoSessionTestRule extend
                     "      prefs.set(name, value);" +
                     "    }" +
                     "  }" +
                     "})()", JSONObject.quote(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();
@@ -722,29 +819,28 @@ public class GeckoSessionTestRule extend
             return;
         }
         final Class<?>[] superIfces = ifce.getInterfaces();
         for (final Class<?> superIfce : superIfces) {
             addCallbackClasses(list, superIfce);
         }
     }
 
-    private static Class<?>[] getCallbackClasses() {
+    private static Set<Class<?>> getDefaultDelegates() {
         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()]);
+        return new HashSet<>(list);
     }
 
-    private static final List<Class<?>> CALLBACK_CLASSES = Arrays.asList(getCallbackClasses());
+    private static final Set<Class<?>> DEFAULT_DELEGATES = getDefaultDelegates();
 
     private static final class TimeoutRunnable implements Runnable {
         private long timeout;
 
         public void set(final long timeout) {
             this.timeout = timeout;
             cancel();
             HANDLER.postDelayed(this, timeout);
@@ -784,16 +880,17 @@ public class GeckoSessionTestRule extend
             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;
@@ -901,20 +998,39 @@ public class GeckoSessionTestRule extend
         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());
     }
 
+    @NonNull
+    private Set<Class<?>> getCurrentDelegates() {
+        final List<ExternalDelegate<?>> waitDelegates = mWaitScopeDelegates.getExternalDelegates();
+        final List<ExternalDelegate<?>> testDelegates = mTestScopeDelegates.getExternalDelegates();
+
+        if (waitDelegates.isEmpty() && testDelegates.isEmpty()) {
+            return DEFAULT_DELEGATES;
+        }
+
+        final Set<Class<?>> set = new HashSet<>(DEFAULT_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) {
         if (!Callbacks.class.equals(delegate.getDeclaringClass())) {
             assertThat("Null-delegate must be valid interface class",
-                       delegate, isIn(CALLBACK_CLASSES));
+                       delegate, isIn(DEFAULT_DELEGATES));
             mNullDelegates.add(delegate);
             return;
         }
         for (final Class<?> ifce : delegate.getInterfaces()) {
             addNullDelegate(ifce);
         }
     }
 
@@ -999,36 +1115,54 @@ public class GeckoSessionTestRule extend
         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 isExternalDelegate =
+                        !DEFAULT_DELEGATES.contains(method.getDeclaringClass());
                 if (!ignore) {
                     assertThat("Callbacks must be on UI thread",
                                Looper.myLooper(), equalTo(Looper.getMainLooper()));
-                    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));
 
-                    final GeckoSession session = (GeckoSession) args[0];
+                    final GeckoSession session;
+                    if (isExternalDelegate) {
+                        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];
+                    }
                     records.add(new CallRecord(session, method, args));
 
                     call = waitDelegates.prepareMethodCall(session, method);
                     if (call == null) {
                         call = testDelegates.prepareMethodCall(session, method);
                     }
+
+                    if (isExternalDelegate) {
+                        assertThat("External delegate should be registered",
+                                   call, notNullValue());
+                    }
                 }
 
                 if (call != null && sOnNewSession.equals(method)) {
                     // We're delegating an onNewSession call.
                     // Make sure we wait on the newly opened session, if any.
                     final GeckoSession oldSession = (GeckoSession) args[0];
                     @SuppressWarnings("unchecked")
                     final GeckoResponse<GeckoSession> realResponse =
@@ -1052,19 +1186,21 @@ public class GeckoSessionTestRule extend
                 } catch (final IllegalAccessException | InvocationTargetException e) {
                     throw unwrapRuntimeException(e);
                 } finally {
                     mCurrentMethodCall = null;
                 }
             }
         };
 
-        final Class<?>[] classes = CALLBACK_CLASSES.toArray(new Class<?>[CALLBACK_CLASSES.size()]);
+        final Class<?>[] classes = DEFAULT_DELEGATES.toArray(
+                new Class<?>[DEFAULT_DELEGATES.size()]);
         mCallbackProxy = Proxy.newProxyInstance(GeckoSession.class.getClassLoader(),
                                                 classes, recorder);
+        mAllDelegates = new HashSet<>(DEFAULT_DELEGATES);
 
         if (sRuntime == null) {
             final GeckoRuntimeSettings.Builder runtimeSettingsBuilder =
                 new GeckoRuntimeSettings.Builder();
             runtimeSettingsBuilder.arguments(new String[] { "-purgecaches" })
                     .extras(InstrumentationRegistry.getArguments())
                     .nativeCrashReportingEnabled(true)
                     .javaCrashReportingEnabled(true)
@@ -1105,17 +1241,17 @@ public class GeckoSessionTestRule extend
                 waitForOpenSession(mMainSession);
             }
         } else if (!mClosedSession) {
             openSession(mMainSession);
         }
     }
 
     protected void prepareSession(final GeckoSession session) throws Throwable {
-        for (final Class<?> cls : CALLBACK_CLASSES) {
+        for (final Class<?> cls : DEFAULT_DELEGATES) {
             getCallbackSetter(cls).invoke(
                     session, mNullDelegates.contains(cls) ? null : mCallbackProxy);
         }
     }
 
     /**
      * 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.
@@ -1157,17 +1293,18 @@ public class GeckoSessionTestRule extend
         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 = new CallRecordHandler() {
                 @Override
                 public boolean handleCall(final Method method, final Object[] args) {
-                    final boolean matching = session.equals(args[0]);
+                    final boolean matching = DEFAULT_DELEGATES.contains(
+                            method.getDeclaringClass()) && session.equals(args[0]);
                     if (matching && sOnPageStop.equals(method)) {
                         mCallRecordHandler = null;
                     }
                     return matching;
                 }
             };
 
             do {
@@ -1220,16 +1357,17 @@ public class GeckoSessionTestRule extend
             mDisplaySurface.release();
             mDisplaySurface = null;
             mDisplayTexture.release();
             mDisplayTexture = null;
         }
 
         mMainSession = null;
         mCallbackProxy = null;
+        mAllDelegates = null;
         mNullDelegates = null;
         mCallRecords = null;
         mWaitScopeDelegates = null;
         mTestScopeDelegates = null;
         mLastWaitStart = 0;
         mLastWaitEnd = 0;
         mTimeoutMillis = 0;
         mRDPTabs = null;
@@ -1408,34 +1546,35 @@ public class GeckoSessionTestRule extend
         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 : CALLBACK_CLASSES) {
+        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,
                                                    /* requirement */ null));
                     break;
                 }
             }
             isSessionCallback = true;
         }
 
-        assertThat("Class should be a GeckoSession interface",
+        assertThat("Delegate should be a GeckoSession delegate " +
+                           "or registered external delegate",
                    isSessionCallback, equalTo(true));
 
         waitUntilCalled(session, callback, waitMethods);
     }
 
     /**
      * 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
@@ -1460,17 +1599,19 @@ public class GeckoSessionTestRule extend
     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<>();
-        for (final Class<?> ifce : CALLBACK_CLASSES) {
+        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());
@@ -1478,34 +1619,39 @@ public class GeckoSessionTestRule extend
                     throw new RuntimeException(e);
                 }
                 final AssertCalled ac = getAssertCalled(callbackMethod, callback);
                 if (ac != null && ac.value()) {
                     methodCalls.add(new MethodCall(session, method,
                                                    ac, /* target */ null));
                 }
             }
+            isSessionCallback = true;
         }
 
+        assertThat("Delegate should implement a GeckoSession delegate " +
+                           "or registered external delegate",
+                   isSessionCallback, equalTo(true));
+
         waitUntilCalled(session, callback.getClass(), methodCalls);
         forCallbacksDuringWait(session, callback);
     }
 
     protected void waitUntilCalled(final @Nullable GeckoSession session,
                                    final @NonNull Class<?> delegate,
                                    final @NonNull List<MethodCall> methodCalls) {
         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 : CALLBACK_CLASSES) {
+        for (final Class<?> ifce : DEFAULT_DELEGATES) {
             final Object callback;
             try {
                 callback = getCallbackGetter(ifce).invoke(session == null ? mMainSession : session);
             } catch (final NoSuchMethodException | IllegalAccessException |
                     InvocationTargetException e) {
                 throw unwrapRuntimeException(e);
             }
             if (mNullDelegates.contains(ifce)) {
@@ -1561,16 +1707,22 @@ public class GeckoSessionTestRule extend
 
     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.
@@ -1595,17 +1747,17 @@ public class GeckoSessionTestRule extend
      */
     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 : CALLBACK_CLASSES) {
+        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;
@@ -1634,17 +1786,18 @@ public class GeckoSessionTestRule extend
         }
 
         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 && record.args[0] != session)) {
+                    (session != null && DEFAULT_DELEGATES.contains(
+                            record.method.getDeclaringClass()) && record.args[0] != session)) {
                 continue;
             }
 
             final int i = methodCalls.indexOf(record.methodCall);
             checkThat(record.method.getName() + " should be found",
                       i, greaterThanOrEqualTo(0));
 
             final MethodCall methodCall = methodCalls.get(i);
@@ -2019,9 +2172,92 @@ public class GeckoSessionTestRule extend
      */
     public void forceGarbageCollection() {
         assertThat("Must enable RDP using @WithDevToolsAPI",
                    mWithDevTools, equalTo(true));
         ensureChromeProcess();
         mRDPChromeProcess.getMemory().forceCycleCollection();
         mRDPChromeProcess.getMemory().forceGarbageCollection();
     }
+
+    /**
+     * 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);
+    }
 }