Automated VR keyboard update prompt tests

Automates the two manual tests for ensuring that attempting to use
keyboard input while in the VR browser without the keyboard APK
installed triggers a DOFF prompt to install or updated the APK.

As a side effect, also adds the ability to wait for an element
in the native UI to change visibility.

Bug: 887588
Cq-Include-Trybots: luci.chromium.try:android_optional_gpu_tests_rel;luci.chromium.try:linux_optional_gpu_tests_rel;luci.chromium.try:linux_vr;luci.chromium.try:mac_optional_gpu_tests_rel;luci.chromium.try:win_optional_gpu_tests_rel
Change-Id: Ie4e08317c82fc2f4cc5379a5d18a82994f34a804
Reviewed-on: https://chromium-review.googlesource.com/c/1246279
Commit-Queue: Brian Sheedy <[email protected]>
Reviewed-by: Aldo Culquicondor <[email protected]>
Reviewed-by: Michael Thiessen <[email protected]>
Cr-Commit-Position: refs/heads/master@{#595916}
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/vr/VrShell.java b/chrome/android/java/src/org/chromium/chrome/browser/vr/VrShell.java
index ea181134..b8d08db 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/vr/VrShell.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/vr/VrShell.java
@@ -1252,7 +1252,7 @@
     }
 
     public void registerUiOperationCallbackForTesting(
-            int actionType, Runnable resultCallback, int quiescenceTimeoutMs) {
+            int actionType, Runnable resultCallback, int timeoutMs, int elementName) {
         assert actionType < UiTestOperationType.NUM_UI_TEST_OPERATION_TYPES;
         // Fill the ArrayLists if this is the first time the method has been called.
         if (mUiOperationResults == null) {
@@ -1265,16 +1265,15 @@
                 mUiOperationResultCallbacks.add(null);
             }
         }
-        // We currently have two callback types, and only one of them actually cares about the
-        // value given to the callback, so we can blindly set the default here. If more are added,
-        // their defaults will have to be properly set here.
-        mUiOperationResults.set(actionType, VrUiTestActivityResult.UNREPORTED);
+        mUiOperationResults.set(actionType, UiTestOperationResult.UNREPORTED);
         mUiOperationResultCallbacks.set(actionType, resultCallback);
 
         // In the case of the UI activity quiescence callback type, we need to let the native UI
         // know how long to wait before timing out.
         if (actionType == UiTestOperationType.UI_ACTIVITY_RESULT) {
-            nativeSetUiExpectingActivityForTesting(mNativeVrShell, quiescenceTimeoutMs);
+            nativeSetUiExpectingActivityForTesting(mNativeVrShell, timeoutMs);
+        } else if (actionType == UiTestOperationType.ELEMENT_VISIBILITY_CHANGE) {
+            nativeWatchElementForVisibilityChangeForTesting(mNativeVrShell, elementName, timeoutMs);
         }
     }
 
@@ -1344,6 +1343,8 @@
             long nativeVrShell, int quiescenceTimeoutMs);
     private native void nativeSaveNextFrameBufferToDiskForTesting(
             long nativeVrShell, String filepathBase);
+    private native void nativeWatchElementForVisibilityChangeForTesting(
+            long nativeVrShell, int elementName, int timeoutMs);
     private native void nativeResumeContentRendering(long nativeVrShell);
     private native void nativeOnOverlayTextureEmptyChanged(long nativeVrShell, boolean empty);
 }
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/vr/keyboard/GvrKeyboardLoaderClient.java b/chrome/android/java/src/org/chromium/chrome/browser/vr/keyboard/GvrKeyboardLoaderClient.java
index 347c2be..39dfa74 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/vr/keyboard/GvrKeyboardLoaderClient.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/vr/keyboard/GvrKeyboardLoaderClient.java
@@ -17,6 +17,7 @@
 
 import org.chromium.base.ContextUtils;
 import org.chromium.base.Log;
+import org.chromium.base.VisibleForTesting;
 import org.chromium.base.annotations.CalledByNative;
 import org.chromium.base.annotations.JNINamespace;
 
@@ -31,6 +32,7 @@
 
     private static IGvrKeyboardLoader sLoader;
     private static ClassLoader sRemoteClassLoader;
+    private static boolean sFailLoadForTesting;
     // GVR doesn't support setting the context twice in the application's lifetime and crashes if we
     // do so. Setting the same context wrapper is a no-op, so we keep a reference to the one we
     // create and use it across re-initialization of the keyboard api.
@@ -67,7 +69,13 @@
         }
     }
 
+    @VisibleForTesting
+    public static void setFailLoadForTesting(boolean shouldFail) {
+        sFailLoadForTesting = shouldFail;
+    }
+
     private static IGvrKeyboardLoader getLoader() {
+        if (sFailLoadForTesting) return null;
         if (sLoader == null) {
             ClassLoader remoteClassLoader = (ClassLoader) getRemoteClassLoader();
             if (remoteClassLoader != null) {
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/vr/TestVrShellDelegate.java b/chrome/android/javatests/src/org/chromium/chrome/browser/vr/TestVrShellDelegate.java
index c8e4fee8..9302b9e 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/vr/TestVrShellDelegate.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/vr/TestVrShellDelegate.java
@@ -95,9 +95,9 @@
     }
 
     public void registerUiOperationCallbackForTesting(
-            int actionType, Runnable resultCallback, int quiescenceTimeoutMs) {
+            int actionType, Runnable resultCallback, int timeoutMs, int elementName) {
         getVrShell().registerUiOperationCallbackForTesting(
-                actionType, resultCallback, quiescenceTimeoutMs);
+                actionType, resultCallback, timeoutMs, elementName);
     }
 
     public void saveNextFrameBufferToDiskForTesting(String filepathBase) {
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/vr/VrInstallUpdateInfoBarTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/vr/VrInstallUpdateInfoBarTest.java
index e1539b1..3cd7df9 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/vr/VrInstallUpdateInfoBarTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/vr/VrInstallUpdateInfoBarTest.java
@@ -4,8 +4,12 @@
 
 package org.chromium.chrome.browser.vr;
 
+import static org.chromium.chrome.browser.vr.XrTestFramework.PAGE_LOAD_TIMEOUT_S;
+import static org.chromium.chrome.browser.vr.XrTestFramework.POLL_TIMEOUT_LONG_MS;
 import static org.chromium.chrome.test.util.ChromeRestriction.RESTRICTION_TYPE_SVR;
+import static org.chromium.chrome.test.util.ChromeRestriction.RESTRICTION_TYPE_VIEWER_DAYDREAM;
 
+import android.graphics.PointF;
 import android.os.Build;
 import android.support.test.filters.MediumTest;
 import android.view.View;
@@ -26,7 +30,10 @@
 import org.chromium.base.test.util.Restriction;
 import org.chromium.chrome.R;
 import org.chromium.chrome.browser.ChromeSwitches;
+import org.chromium.chrome.browser.vr.keyboard.GvrKeyboardLoaderClient;
 import org.chromium.chrome.browser.vr.rules.XrActivityRestriction;
+import org.chromium.chrome.browser.vr.util.NativeUiUtils;
+import org.chromium.chrome.browser.vr.util.VrBrowserTransitionUtils;
 import org.chromium.chrome.browser.vr.util.VrInfoBarUtils;
 import org.chromium.chrome.browser.vr.util.VrShellDelegateUtils;
 import org.chromium.chrome.browser.vr.util.VrTestRuleUtils;
@@ -39,7 +46,7 @@
 /**
  * End-to-end tests for the InfoBar that prompts the user to update or install
  * VrCore (VR Services) when attempting to use a VR feature with an outdated
- * or entirely missing version.
+ * or entirely missing version or other VR-related update prompts.
  */
 @RunWith(ParameterizedRunner.class)
 @UseRunnerDelegate(ChromeJUnit4RunnerDelegate.class)
@@ -143,4 +150,49 @@
     public void testInfoBarNotPresentWhenVrServicesNotSupported() throws InterruptedException {
         infoBarTestHelper(VrCoreCompatibility.VR_NOT_SUPPORTED);
     }
+
+    /**
+     * Tests that the install/upgrade prompt for the keyboard appears when clicking on the URL
+     * bar without the keyboard installed.
+     */
+    @Test
+    @MediumTest
+    @Restriction(RESTRICTION_TYPE_VIEWER_DAYDREAM)
+    public void testKeyboardInstallUpgradePromptUrlBar() throws InterruptedException {
+        testKeyboardInstallUpgradeImpl(UserFriendlyElementName.URL);
+    }
+
+    /**
+     * Tests that the install/upgrade prompt for the keyboard appears when interacting with a web
+     * text input field without the keyboard installed.
+     */
+    @Test
+    @MediumTest
+    @Restriction(RESTRICTION_TYPE_VIEWER_DAYDREAM)
+    public void testKeyboardInstallUpgradePromptWebInput() throws InterruptedException {
+        testKeyboardInstallUpgradeImpl(UserFriendlyElementName.CONTENT_QUAD);
+    }
+
+    private void testKeyboardInstallUpgradeImpl(final int uiElementToClick)
+            throws InterruptedException {
+        mVrTestRule.loadUrl(
+                VrBrowserTestFramework.getFileUrlForHtmlTestFile("test_web_input_editing"),
+                PAGE_LOAD_TIMEOUT_S);
+        GvrKeyboardLoaderClient.setFailLoadForTesting(true);
+        VrBrowserTransitionUtils.forceEnterVrBrowserOrFail(POLL_TIMEOUT_LONG_MS);
+        // The prompt takes significantly longer to show when clicking on the web content, so we
+        // can't just wait for quiescence since that gets reached before the prompt shows (not sure
+        // what's causing a UI change other than the prompt). Instead, explicitly wait for the
+        // prompt to become visible before waiting for quiescence.
+        NativeUiUtils.performActionAndWaitForUiQuiescence(() -> {
+            try {
+                NativeUiUtils.performActionAndWaitForVisibilityChange(
+                        UserFriendlyElementName.EXIT_PROMPT,
+                        () -> { NativeUiUtils.clickElement(uiElementToClick, new PointF()); });
+            } catch (InterruptedException e) {
+                Assert.fail("Interrupted while waiting for UI visibility change");
+            }
+
+        });
+    }
 }
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/vr/util/NativeUiUtils.java b/chrome/android/javatests/src/org/chromium/chrome/browser/vr/util/NativeUiUtils.java
index a882d22..fbeead5 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/vr/util/NativeUiUtils.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/vr/util/NativeUiUtils.java
@@ -15,12 +15,12 @@
 import org.chromium.chrome.R;
 import org.chromium.chrome.browser.ChromeActivity;
 import org.chromium.chrome.browser.vr.TestVrShellDelegate;
+import org.chromium.chrome.browser.vr.UiTestOperationResult;
 import org.chromium.chrome.browser.vr.UiTestOperationType;
 import org.chromium.chrome.browser.vr.UserFriendlyElementName;
 import org.chromium.chrome.browser.vr.VrControllerTestAction;
 import org.chromium.chrome.browser.vr.VrDialog;
 import org.chromium.chrome.browser.vr.VrShell;
-import org.chromium.chrome.browser.vr.VrUiTestActivityResult;
 import org.chromium.chrome.browser.vr.VrViewContainer;
 import org.chromium.content_public.browser.test.util.CriteriaHelper;
 
@@ -139,8 +139,10 @@
         // Run on the UI thread to prevent issues with registering a new callback before
         // ReportUiOperationResultForTesting has finished.
         ThreadUtils.runOnUiThreadBlocking(() -> {
-            instance.registerUiOperationCallbackForTesting(UiTestOperationType.UI_ACTIVITY_RESULT,
-                    () -> { resultLatch.countDown(); }, DEFAULT_UI_QUIESCENCE_TIMEOUT_MS);
+            instance.registerUiOperationCallbackForTesting(
+                    UiTestOperationType.UI_ACTIVITY_RESULT, () -> {
+                        resultLatch.countDown();
+                    }, DEFAULT_UI_QUIESCENCE_TIMEOUT_MS, 0 /* unused */);
         });
         action.run();
 
@@ -149,8 +151,37 @@
         int uiResult =
                 instance.getLastUiOperationResultForTesting(UiTestOperationType.UI_ACTIVITY_RESULT);
         Assert.assertEquals("UI reported non-quiescent result '"
-                        + vrUiTestActivityResultToString(uiResult) + "'",
-                VrUiTestActivityResult.QUIESCENT, uiResult);
+                        + uiTestOperationResultToString(uiResult) + "'",
+                UiTestOperationResult.QUIESCENT, uiResult);
+    }
+
+    /**
+     * Runs the given Runnable and waits until the specified element changes its visibility.
+     *
+     * @param elementName The UserFriendlyElementName to wait on to change visibility.
+     * @param action A Runnable containing the action to perform.
+     */
+    public static void performActionAndWaitForVisibilityChange(
+            final int elementName, Runnable action) throws InterruptedException {
+        final TestVrShellDelegate instance = TestVrShellDelegate.getInstance();
+        final CountDownLatch resultLatch = new CountDownLatch(1);
+        // Run on the UI thread to prevent issues with registering a new callback before
+        // ReportUiOperationResultForTesting has finished.
+        ThreadUtils.runOnUiThreadBlocking(() -> {
+            instance.registerUiOperationCallbackForTesting(
+                    UiTestOperationType.ELEMENT_VISIBILITY_CHANGE, () -> {
+                        resultLatch.countDown();
+                    }, DEFAULT_UI_QUIESCENCE_TIMEOUT_MS, elementName);
+        });
+        action.run();
+
+        // Wait for the result to be reported.
+        resultLatch.await();
+        int result = instance.getLastUiOperationResultForTesting(
+                UiTestOperationType.ELEMENT_VISIBILITY_CHANGE);
+        Assert.assertEquals("UI reported non-visibility-changed result '"
+                        + uiTestOperationResultToString(result) + "'",
+                UiTestOperationResult.VISIBILITY_CHANGE, result);
     }
 
     /**
@@ -197,7 +228,7 @@
         // ReportUiOperationResultForTesting has finished.
         ThreadUtils.runOnUiThreadBlocking(() -> {
             instance.registerUiOperationCallbackForTesting(UiTestOperationType.FRAME_BUFFER_DUMPED,
-                    () -> { resultLatch.countDown(); }, 0 /* unused */);
+                    () -> { resultLatch.countDown(); }, 0 /* unused */, 0 /* unused */);
         });
         instance.saveNextFrameBufferToDiskForTesting(filepathBase);
         resultLatch.await();
@@ -244,16 +275,20 @@
         clickElementAndWaitForUiQuiescence(UserFriendlyElementName.BROWSING_DIALOG, buttonCenter);
     }
 
-    private static String vrUiTestActivityResultToString(int result) {
+    private static String uiTestOperationResultToString(int result) {
         switch (result) {
-            case VrUiTestActivityResult.UNREPORTED:
+            case UiTestOperationResult.UNREPORTED:
                 return "Unreported";
-            case VrUiTestActivityResult.QUIESCENT:
+            case UiTestOperationResult.QUIESCENT:
                 return "Quiescent";
-            case VrUiTestActivityResult.TIMEOUT_NO_START:
+            case UiTestOperationResult.TIMEOUT_NO_START:
                 return "Timeout (UI activity not started)";
-            case VrUiTestActivityResult.TIMEOUT_NO_END:
+            case UiTestOperationResult.TIMEOUT_NO_END:
                 return "Timeout (UI activity not stopped)";
+            case UiTestOperationResult.VISIBILITY_CHANGE:
+                return "Visibility change";
+            case UiTestOperationResult.TIMEOUT_NO_CHANGE:
+                return "Timeout (Element visibility did not change)";
             default:
                 return "Unknown result";
         }
diff --git a/chrome/browser/android/vr/vr_gl_thread.cc b/chrome/browser/android/vr/vr_gl_thread.cc
index 623ba72..abb2294 100644
--- a/chrome/browser/android/vr/vr_gl_thread.cc
+++ b/chrome/browser/android/vr/vr_gl_thread.cc
@@ -518,7 +518,7 @@
 
 void VrGLThread::ReportUiOperationResultForTesting(
     const UiTestOperationType& action_type,
-    const VrUiTestActivityResult& result) {
+    const UiTestOperationResult& result) {
   DCHECK(OnGlThread());
   main_thread_task_runner_->PostTask(
       FROM_HERE, base::BindOnce(&VrShell::ReportUiOperationResultForTesting,
diff --git a/chrome/browser/android/vr/vr_gl_thread.h b/chrome/browser/android/vr/vr_gl_thread.h
index 120bee4..a1f4b211 100644
--- a/chrome/browser/android/vr/vr_gl_thread.h
+++ b/chrome/browser/android/vr/vr_gl_thread.h
@@ -78,7 +78,7 @@
   void ForceExitVr() override;
   void ReportUiOperationResultForTesting(
       const UiTestOperationType& action_type,
-      const VrUiTestActivityResult& result) override;
+      const UiTestOperationResult& result) override;
 
   // PlatformInputHandler
   void ForwardEventToPlatformUi(std::unique_ptr<InputEvent> event) override;
diff --git a/chrome/browser/android/vr/vr_shell.cc b/chrome/browser/android/vr/vr_shell.cc
index 79b864c..181b8765 100644
--- a/chrome/browser/android/vr/vr_shell.cc
+++ b/chrome/browser/android/vr/vr_shell.cc
@@ -1280,8 +1280,24 @@
           base::android::ConvertJavaStringToUTF8(env, filepath_base)));
 }
 
+void VrShell::WatchElementForVisibilityChangeForTesting(
+    JNIEnv* env,
+    const base::android::JavaParamRef<jobject>& obj,
+    jint element_name,
+    jint timeout_ms) {
+  VisibilityChangeExpectation visibility_expectation;
+  visibility_expectation.element_name =
+      static_cast<UserFriendlyElementName>(element_name);
+  visibility_expectation.timeout_ms = timeout_ms;
+  PostToGlThread(
+      FROM_HERE,
+      base::BindOnce(
+          &BrowserRenderer::WatchElementForVisibilityChangeForTesting,
+          gl_thread_->GetBrowserRenderer(), visibility_expectation));
+}
+
 void VrShell::ReportUiOperationResultForTesting(UiTestOperationType action_type,
-                                                VrUiTestActivityResult result) {
+                                                UiTestOperationResult result) {
   JNIEnv* env = base::android::AttachCurrentThread();
   Java_VrShell_reportUiOperationResultForTesting(env, j_vr_shell_,
                                                  static_cast<int>(action_type),
diff --git a/chrome/browser/android/vr/vr_shell.h b/chrome/browser/android/vr/vr_shell.h
index 043cbe3..973f634 100644
--- a/chrome/browser/android/vr/vr_shell.h
+++ b/chrome/browser/android/vr/vr_shell.h
@@ -57,7 +57,7 @@
 class VrShellDelegate;
 class VrWebContentsObserver;
 enum class UiTestOperationType;
-enum class VrUiTestActivityResult;
+enum class UiTestOperationResult;
 struct Assets;
 struct AutocompleteRequest;
 
@@ -286,8 +286,14 @@
       const base::android::JavaParamRef<jobject>& obj,
       jstring filepath_base);
 
+  void WatchElementForVisibilityChangeForTesting(
+      JNIEnv* env,
+      const base::android::JavaParamRef<jobject>& obj,
+      jint element_name,
+      jint timeout_ms);
+
   void ReportUiOperationResultForTesting(UiTestOperationType action_type,
-                                         VrUiTestActivityResult result);
+                                         UiTestOperationResult result);
 
   void PerformControllerActionForTesting(
       JNIEnv* env,
diff --git a/chrome/browser/vr/browser_renderer.cc b/chrome/browser/vr/browser_renderer.cc
index e81b8c6..cd9fcc76 100644
--- a/chrome/browser/vr/browser_renderer.cc
+++ b/chrome/browser/vr/browser_renderer.cc
@@ -256,6 +256,19 @@
   frame_buffer_dump_filepath_base_ = filepath_base;
 }
 
+void BrowserRenderer::WatchElementForVisibilityChangeForTesting(
+    VisibilityChangeExpectation visibility_expectation) {
+  DCHECK(ui_visibility_state_ == nullptr) << "Attempted to watch a UI element "
+                                             "for visibility changes with one "
+                                             "in progress";
+  ui_visibility_state_ = std::make_unique<UiVisibilityState>();
+  ui_visibility_state_->timeout_ms =
+      base::TimeDelta::FromMilliseconds(visibility_expectation.timeout_ms);
+  ui_visibility_state_->element_to_watch = visibility_expectation.element_name;
+  ui_visibility_state_->initially_visible = ui_->GetElementVisibilityForTesting(
+      ui_visibility_state_->element_to_watch);
+}
+
 void BrowserRenderer::AcceptDoffPromptForTesting() {
   ui_->AcceptDoffPromptForTesting();
 }
@@ -283,6 +296,7 @@
     ui_updated = true;
   }
   ReportUiStatusForTesting(timing_start, ui_updated);
+  ReportElementVisibilityStatusForTesting(timing_start);
 
   base::TimeDelta scene_time = base::TimeTicks::Now() - timing_start;
   // Don't double-count the controller time that was part of the scene time.
@@ -374,16 +388,16 @@
     if (time_since_start > ui_test_state_->quiescence_timeout_ms) {
       // The UI is being updated, but hasn't reached a stable state in the
       // given time -> report timeout.
-      ReportUiActivityResultForTesting(VrUiTestActivityResult::kTimeoutNoEnd);
+      ReportUiActivityResultForTesting(UiTestOperationResult::kTimeoutNoEnd);
     }
   } else {
     if (ui_test_state_->activity_started) {
       // The UI has been updated since the test requested notification of
       // quiescence, but wasn't this frame -> report that the UI is quiescent.
-      ReportUiActivityResultForTesting(VrUiTestActivityResult::kQuiescent);
+      ReportUiActivityResultForTesting(UiTestOperationResult::kQuiescent);
     } else if (time_since_start > ui_test_state_->quiescence_timeout_ms) {
       // The UI has never been updated and we've reached the timeout.
-      ReportUiActivityResultForTesting(VrUiTestActivityResult::kTimeoutNoStart);
+      ReportUiActivityResultForTesting(UiTestOperationResult::kTimeoutNoStart);
     }
   }
 }
@@ -393,7 +407,7 @@
 }
 
 void BrowserRenderer::ReportUiActivityResultForTesting(
-    VrUiTestActivityResult result) {
+    UiTestOperationResult result) {
   ui_test_state_ = nullptr;
   browser_->ReportUiOperationResultForTesting(
       UiTestOperationType::kUiActivityResult, result);
@@ -406,7 +420,31 @@
   frame_buffer_dump_filepath_base_.clear();
   browser_->ReportUiOperationResultForTesting(
       UiTestOperationType::kFrameBufferDumped,
-      VrUiTestActivityResult::kQuiescent /* unused */);
+      UiTestOperationResult::kQuiescent /* unused */);
+}
+
+void BrowserRenderer::ReportElementVisibilityStatusForTesting(
+    const base::TimeTicks& current_time) {
+  if (ui_visibility_state_ == nullptr)
+    return;
+  base::TimeDelta time_since_start =
+      current_time - ui_visibility_state_->start_time;
+  if (ui_->GetElementVisibilityForTesting(
+          ui_visibility_state_->element_to_watch) !=
+      ui_visibility_state_->initially_visible) {
+    ReportElementVisibilityResultForTesting(
+        UiTestOperationResult::kVisibilityChange);
+  } else if (time_since_start > ui_visibility_state_->timeout_ms) {
+    ReportElementVisibilityResultForTesting(
+        UiTestOperationResult::kTimeoutNoChange);
+  }
+}
+
+void BrowserRenderer::ReportElementVisibilityResultForTesting(
+    UiTestOperationResult result) {
+  ui_visibility_state_ = nullptr;
+  browser_->ReportUiOperationResultForTesting(
+      UiTestOperationType::kElementVisibilityChange, result);
 }
 
 }  // namespace vr
diff --git a/chrome/browser/vr/browser_renderer.h b/chrome/browser/vr/browser_renderer.h
index a53539d..ab3bafc 100644
--- a/chrome/browser/vr/browser_renderer.h
+++ b/chrome/browser/vr/browser_renderer.h
@@ -24,7 +24,7 @@
 
 namespace vr {
 
-enum class VrUiTestActivityResult;
+enum class UiTestOperationResult;
 class BrowserUiInterface;
 class InputDelegate;
 class PlatformInputHandler;
@@ -35,7 +35,9 @@
 struct ControllerTestInput;
 struct RenderInfo;
 struct UiTestActivityExpectation;
+struct VisibilityChangeExpectation;
 struct UiTestState;
+struct UiVisibilityState;
 
 // The BrowserRenderer handles all input/output activities during a frame.
 // This includes head movement, controller movement and input, audio output and
@@ -78,6 +80,8 @@
   void SetUiExpectingActivityForTesting(
       UiTestActivityExpectation ui_expectation);
   void SaveNextFrameBufferToDiskForTesting(std::string filepath_base);
+  void WatchElementForVisibilityChangeForTesting(
+      VisibilityChangeExpectation visibility_expectation);
   void AcceptDoffPromptForTesting();
   void ConnectPresentingService(
       device::mojom::VRDisplayInfoPtr display_info,
@@ -108,8 +112,11 @@
 
   void ReportUiStatusForTesting(const base::TimeTicks& current_time,
                                 bool ui_updated);
-  void ReportUiActivityResultForTesting(VrUiTestActivityResult result);
+  void ReportUiActivityResultForTesting(UiTestOperationResult result);
   void ReportFrameBufferDumpForTesting();
+  void ReportElementVisibilityStatusForTesting(
+      const base::TimeTicks& current_time);
+  void ReportElementVisibilityResultForTesting(UiTestOperationResult result);
 
   std::unique_ptr<UiInterface> ui_;
   std::unique_ptr<SchedulerDelegate> scheduler_delegate_;
@@ -124,6 +131,7 @@
   BrowserRendererBrowserInterface* browser_;
 
   std::unique_ptr<UiTestState> ui_test_state_;
+  std::unique_ptr<UiVisibilityState> ui_visibility_state_;
   SlidingTimeDeltaAverage ui_processing_time_;
   SlidingTimeDeltaAverage ui_controller_update_time_;
 
diff --git a/chrome/browser/vr/browser_renderer_browser_interface.h b/chrome/browser/vr/browser_renderer_browser_interface.h
index 8ce4826..f2103bf 100644
--- a/chrome/browser/vr/browser_renderer_browser_interface.h
+++ b/chrome/browser/vr/browser_renderer_browser_interface.h
@@ -17,7 +17,7 @@
   virtual void ForceExitVr() = 0;
   virtual void ReportUiOperationResultForTesting(
       const UiTestOperationType& action_type,
-      const VrUiTestActivityResult& result) = 0;
+      const UiTestOperationResult& result) = 0;
 };
 
 }  // namespace vr
diff --git a/chrome/browser/vr/browser_renderer_unittest.cc b/chrome/browser/vr/browser_renderer_unittest.cc
index 47bc5de..797df89 100644
--- a/chrome/browser/vr/browser_renderer_unittest.cc
+++ b/chrome/browser/vr/browser_renderer_unittest.cc
@@ -52,6 +52,7 @@
   MOCK_METHOD2(GetTargetPointForTesting,
                gfx::Point3F(UserFriendlyElementName,
                             const gfx::PointF& position));
+  MOCK_METHOD1(GetElementVisibilityForTesting, bool(UserFriendlyElementName));
   MOCK_METHOD0(IsContentVisibleAndOpaque, bool());
   MOCK_METHOD1(SetContentUsesQuadLayer, void(bool));
   gfx::Transform GetContentWorldSpaceTransform() override { return {}; }
diff --git a/chrome/browser/vr/ui.cc b/chrome/browser/vr/ui.cc
index ce47c6b..8f66e5db 100644
--- a/chrome/browser/vr/ui.cc
+++ b/chrome/browser/vr/ui.cc
@@ -67,6 +67,8 @@
       return kOverflowMenuNewIncognitoTabItem;
     case UserFriendlyElementName::kCloseIncognitoTabs:
       return kOverflowMenuCloseAllIncognitoTabsItem;
+    case UserFriendlyElementName::kExitPrompt:
+      return kExitPrompt;
     default:
       NOTREACHED();
       return kNone;
@@ -547,6 +549,13 @@
   InitializeModel(ui_initial_state);
 }
 
+bool Ui::GetElementVisibilityForTesting(UserFriendlyElementName element_name) {
+  auto* target_element = scene()->GetUiElementByName(
+      UserFriendlyElementNameToUiElementName(element_name));
+  DCHECK(target_element) << "Unsupported test element";
+  return target_element->IsVisible();
+}
+
 void Ui::InitializeModel(const UiInitialState& ui_initial_state) {
   model_->speech.has_or_can_request_audio_permission =
       ui_initial_state.has_or_can_request_audio_permission;
diff --git a/chrome/browser/vr/ui.h b/chrome/browser/vr/ui.h
index 9694c41d..8627fa22 100644
--- a/chrome/browser/vr/ui.h
+++ b/chrome/browser/vr/ui.h
@@ -74,6 +74,8 @@
   ContentInputDelegate* GetContentInputDelegateForTest() {
     return content_input_delegate_.get();
   }
+  bool GetElementVisibilityForTesting(
+      UserFriendlyElementName element_name) override;
 
   void Dump(bool include_bindings);
   // TODO(crbug.com/767957): Refactor to hide these behind the UI interface.
diff --git a/chrome/browser/vr/ui_interface.h b/chrome/browser/vr/ui_interface.h
index a5d8551..e30e57f5 100644
--- a/chrome/browser/vr/ui_interface.h
+++ b/chrome/browser/vr/ui_interface.h
@@ -79,6 +79,8 @@
   virtual gfx::Point3F GetTargetPointForTesting(
       UserFriendlyElementName element_name,
       const gfx::PointF& position) = 0;
+  virtual bool GetElementVisibilityForTesting(
+      UserFriendlyElementName element_name) = 0;
   virtual bool IsContentVisibleAndOpaque() = 0;
   virtual void SetContentUsesQuadLayer(bool uses_quad_buffers) = 0;
   virtual gfx::Transform GetContentWorldSpaceTransform() = 0;
diff --git a/chrome/browser/vr/ui_test_input.h b/chrome/browser/vr/ui_test_input.h
index 05171cec9..8de91465f 100644
--- a/chrome/browser/vr/ui_test_input.h
+++ b/chrome/browser/vr/ui_test_input.h
@@ -25,6 +25,7 @@
   kNewIncognitoTab,  // Button to open a new Incognito tab in the overflow menu
   kCloseIncognitoTabs,  // Button to close all Incognito tabs in the overflow
                         // menu
+  kExitPrompt,          // DOFF prompt/request to exit VR
 };
 
 // These are the types of actions that Java can request callbacks for once
@@ -33,17 +34,21 @@
 enum class UiTestOperationType : int {
   kUiActivityResult = 0,     // Result after being told to wait for quiescence
   kFrameBufferDumped,        // Signal that the frame buffer was dumped to disk
+  kElementVisibilityChange,  // Signal that a watched element changed visibility
   kNumUiTestOperationTypes,  // Must be last
 };
 
-// These are used to report the current state of the UI after performing an
-// action
+// These are used to report the result of a UI test operation.
 // GENERATED_JAVA_ENUM_PACKAGE: org.chromium.chrome.browser.vr
-enum class VrUiTestActivityResult : int {
-  kUnreported,
-  kQuiescent,
-  kTimeoutNoStart,
-  kTimeoutNoEnd,
+enum class UiTestOperationResult : int {
+  kUnreported,      // The result has not yet been reported
+  kQuiescent,       // The UI reached quiescence (kUiActivityResult)
+  kTimeoutNoStart,  // Timed out, UI activity never started (kUiActivityResult)
+  kTimeoutNoEnd,    // Timed out, UI activity never finished (kUiActivityResult)
+  kVisibilityChange,  // The watched element's visibility changed
+                      // (kElementVisibilityChange)
+  kTimeoutNoChange,   // Timed out, visibility never changed
+                      // (kElementVisibilityChange)
 };
 
 // These are used to specify what type of action should be performed on a UI
@@ -71,6 +76,11 @@
   int quiescence_timeout_ms;
 };
 
+struct VisibilityChangeExpectation {
+  UserFriendlyElementName element_name;
+  int timeout_ms;
+};
+
 // Holds all the information necessary to keep track of and report whether the
 // UI reacted to test input.
 struct UiTestState {
@@ -83,6 +93,19 @@
   base::TimeTicks start_time = base::TimeTicks::Now();
 };
 
+// Holds all the information necessary to keep track of and report whether a
+// UI element changed visibility in the allotted time.
+struct UiVisibilityState {
+  // The UI element being watched.
+  UserFriendlyElementName element_to_watch = UserFriendlyElementName::kUrl;
+  // The initial visibility state of the element.
+  bool initially_visible = false;
+  // How long to wait for a visibility change before timing out.
+  base::TimeDelta timeout_ms = base::TimeDelta::Min();
+  // The point in time that we started watching for visibility changes.
+  base::TimeTicks start_time = base::TimeTicks::Now();
+};
+
 }  // namespace vr
 
 #endif  // CHROME_BROWSER_VR_UI_TEST_INPUT_H_