summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/androidTest/java
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /mobile/android/geckoview/src/androidTest/java
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/geckoview/src/androidTest/java')
-rw-r--r--mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java15
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt1686
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt1334
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt746
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt230
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt365
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt49
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt185
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt490
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt23
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt314
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt643
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt165
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java546
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt37
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt1737
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt69
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java24
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt221
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt270
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt24
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt159
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt180
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaElementTest.kt414
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt813
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java212
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt1972
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt143
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt498
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt336
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt84
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt504
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt583
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt182
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt419
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt495
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt165
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt405
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt123
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java268
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRunnerActivity.java407
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt926
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt81
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt449
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt2294
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt156
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt245
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java168
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt62
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RemoteGeckoService.kt66
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java2325
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java10
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt65
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java85
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java251
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt167
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java164
57 files changed, 25049 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java b/mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java
new file mode 100644
index 0000000000..99d23806fd
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java
@@ -0,0 +1,15 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package android.view.inputmethod;
+
+/**
+ * This dummy class is used when running tests on Android versions prior to 21,
+ * when the CursorAnchorInfo class was first introduced. Without this class,
+ * tests will crash with ClassNotFoundException when the test rule uses reflection
+ * to access the TextInputDelegate interface.
+ */
+public class CursorAnchorInfo {
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt
new file mode 100644
index 0000000000..f2d2a42fa1
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt
@@ -0,0 +1,1686 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+import android.graphics.Rect
+
+import android.os.Build
+import android.os.Bundle
+import android.os.SystemClock
+
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import android.text.InputType
+import android.util.SparseLongArray
+
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityNodeProvider
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityRecord
+import android.view.View
+import android.view.ViewGroup
+import android.widget.EditText
+
+import android.widget.FrameLayout
+
+import org.hamcrest.Matchers.*
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.Before
+import org.junit.After
+import org.junit.Ignore
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.AllowOrDeny
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.Setting
+
+const val DISPLAY_WIDTH = 480
+const val DISPLAY_HEIGHT = 640
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+@WithDisplay(width = DISPLAY_WIDTH, height = DISPLAY_HEIGHT)
+class AccessibilityTest : BaseSessionTest() {
+ lateinit var view: View
+ val screenRect = Rect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT)
+ val provider: AccessibilityNodeProvider get() = view.accessibilityNodeProvider
+ private val nodeInfos = mutableListOf<AccessibilityNodeInfo>()
+
+ // Given a child ID, return the virtual descendent ID.
+ private fun getVirtualDescendantId(childId: Long): Int {
+ try {
+ val getVirtualDescendantIdMethod =
+ AccessibilityNodeInfo::class.java.getMethod("getVirtualDescendantId", Long::class.java)
+ val virtualDescendantId = getVirtualDescendantIdMethod.invoke(null, childId) as Int
+ return if (virtualDescendantId == Int.MAX_VALUE) -1 else virtualDescendantId
+ } catch (ex: Exception) {
+ return 0
+ }
+ }
+
+ // Retrieve the virtual descendent ID of the event's source.
+ private fun getSourceId(event: AccessibilityEvent): Int {
+ try {
+ val getSourceIdMethod =
+ AccessibilityRecord::class.java.getMethod("getSourceNodeId")
+ return getVirtualDescendantId(getSourceIdMethod.invoke(event) as Long)
+ } catch (ex: Exception) {
+ return 0
+ }
+ }
+
+ private fun createNodeInfo(id: Int): AccessibilityNodeInfo {
+ val node = provider.createAccessibilityNodeInfo(id);
+ nodeInfos.add(node)
+ return node;
+ }
+
+ // Get a child ID by index.
+ private fun AccessibilityNodeInfo.getChildId(index: Int): Int =
+ getVirtualDescendantId(
+ if (Build.VERSION.SDK_INT >= 21)
+ AccessibilityNodeInfo::class.java.getMethod(
+ "getChildId", Int::class.java).invoke(this, index) as Long
+ else
+ (AccessibilityNodeInfo::class.java.getMethod("getChildNodeIds")
+ .invoke(this) as SparseLongArray).get(index))
+
+ private interface EventDelegate {
+ fun onAccessibilityFocused(event: AccessibilityEvent) { }
+ fun onAccessibilityFocusCleared(event: AccessibilityEvent) { }
+ fun onClicked(event: AccessibilityEvent) { }
+ fun onFocused(event: AccessibilityEvent) { }
+ fun onSelected(event: AccessibilityEvent) { }
+ fun onScrolled(event: AccessibilityEvent) { }
+ fun onTextSelectionChanged(event: AccessibilityEvent) { }
+ fun onTextChanged(event: AccessibilityEvent) { }
+ fun onTextTraversal(event: AccessibilityEvent) { }
+ fun onWinContentChanged(event: AccessibilityEvent) { }
+ fun onWinStateChanged(event: AccessibilityEvent) { }
+ fun onAnnouncement(event: AccessibilityEvent) { }
+ }
+
+ @Before fun setup() {
+ // We initialize a view with a parent and grandparent so that the
+ // accessibility events propagate up at least to the parent.
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ view = FrameLayout(context)
+ FrameLayout(context).addView(view)
+ FrameLayout(context).addView(view.parent as View)
+
+ // Force on accessibility and assign the session's accessibility
+ // object a view.
+ sessionRule.setPrefsUntilTestEnd(mapOf("accessibility.force_disabled" to -1))
+ mainSession.accessibility.view = view
+
+ // Set up an external delegate that will intercept accessibility events.
+ sessionRule.addExternalDelegateUntilTestEnd(
+ EventDelegate::class,
+ { newDelegate -> (view.parent as View).setAccessibilityDelegate(object : View.AccessibilityDelegate() {
+ override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean {
+ when (event.eventType) {
+ AccessibilityEvent.TYPE_VIEW_FOCUSED -> newDelegate.onFocused(event)
+ AccessibilityEvent.TYPE_VIEW_CLICKED -> newDelegate.onClicked(event)
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED -> newDelegate.onAccessibilityFocused(event)
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED -> newDelegate.onAccessibilityFocusCleared(event)
+ AccessibilityEvent.TYPE_VIEW_SELECTED -> newDelegate.onSelected(event)
+ AccessibilityEvent.TYPE_VIEW_SCROLLED -> newDelegate.onScrolled(event)
+ AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED -> newDelegate.onTextSelectionChanged(event)
+ AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> newDelegate.onTextChanged(event)
+ AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY -> newDelegate.onTextTraversal(event)
+ AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> newDelegate.onWinContentChanged(event)
+ AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> newDelegate.onWinStateChanged(event)
+ AccessibilityEvent.TYPE_ANNOUNCEMENT -> newDelegate.onAnnouncement(event)
+ else -> {}
+ }
+ return false
+ }
+ }) },
+ { (view.parent as View).setAccessibilityDelegate(null) },
+ object : EventDelegate { })
+ }
+
+ @After fun teardown() {
+ sessionRule.session.accessibility.view = null
+ nodeInfos.forEach { node -> node.recycle() }
+ }
+
+ private fun waitForInitialFocus(moveToFirstChild: Boolean = false) {
+ sessionRule.waitUntilCalled(object: GeckoSession.NavigationDelegate {
+ override fun onLoadRequest(session: GeckoSession,
+ request: GeckoSession.NavigationDelegate.LoadRequest)
+ : GeckoResult<AllowOrDeny>? {
+ return GeckoResult.ALLOW
+ }
+ })
+ // XXX: Sometimes we get the window state change of the initial
+ // about:blank page loading. Need to figure out how to ignore that.
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) { }
+
+ @AssertCalled
+ override fun onWinStateChanged(event: AccessibilityEvent) { }
+
+ @AssertCalled
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+
+ if (moveToFirstChild) {
+ provider.performAction(View.NO_ID,
+ AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ }
+ }
+
+ @Test fun testRootNode() {
+ assertThat("provider is not null", provider, notNullValue())
+ val node = createNodeInfo(AccessibilityNodeProvider.HOST_VIEW_ID)
+ assertThat("Root node should have WebView class name",
+ node.className.toString(), equalTo("android.webkit.WebView"))
+ }
+
+ @Test fun testPageLoad() {
+ sessionRule.session.loadTestPath(INPUTS_PATH)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) { }
+ })
+ }
+
+ @Test fun testAccessibilityFocus() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.session.loadTestPath(INPUTS_PATH)
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Label accessibility focused", node.className.toString(),
+ equalTo("android.view.View"))
+ assertThat("Text node should not be focusable", node.isFocusable, equalTo(false))
+ assertThat("Text node should be a11y focused", node.isAccessibilityFocused, equalTo(true))
+ assertThat("Text node should not be clickable", node.isClickable, equalTo(false))
+ }
+ })
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Editbox accessibility focused", node.className.toString(),
+ equalTo("android.widget.EditText"))
+ assertThat("Entry node should be focusable", node.isFocusable, equalTo(true))
+ assertThat("Entry node should be a11y focused", node.isAccessibilityFocused, equalTo(true))
+ assertThat("Entry node should be clickable", node.isClickable, equalTo(true))
+ }
+ })
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocusCleared(event: AccessibilityEvent) {
+ assertThat("Accessibility focused node is now cleared", getSourceId(event), equalTo(nodeId))
+ val node = createNodeInfo(nodeId)
+ assertThat("Entry node should node be a11y focused", node.isAccessibilityFocused, equalTo(false))
+ }
+ })
+ }
+
+ fun loadTestPage(page: String) {
+ sessionRule.session.loadTestPath("/assets/www/accessibility/$page.html")
+ }
+
+ @Test fun testTextEntryNode() {
+ loadTestPage("test-text-entry-node")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').focus()")
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) {
+ val nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Focused EditBox", node.className.toString(),
+ equalTo("android.widget.EditText"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Hint has field name",
+ node.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("Name description"))
+ }
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('input[aria-label=Last]').focus()")
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) {
+ val nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Focused EditBox", node.className.toString(),
+ equalTo("android.widget.EditText"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Hint has field name",
+ node.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("Last, required"))
+ }
+ }
+ })
+ }
+
+ @Test fun testMoveCaretAccessibilityFocus() {
+ loadTestPage("test-move-caret-accessibility-focus")
+ waitForInitialFocus(false)
+
+ mainSession.evaluateJS("""
+ this.select = function select(node, start, end) {
+ let r = new Range();
+ r.setStart(node, start);
+ r.setEnd(node, end);
+ let s = getSelection();
+ s.removeAllRanges();
+ s.addRange(r);
+ };
+ this.select(document.querySelector('p').childNodes[2], 2, 6);
+ """.trimIndent())
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ val node = createNodeInfo(getSourceId(event))
+ assertThat("Text node should match text", node.text as String, equalTo(", sweet "))
+ }
+ })
+
+ mainSession.evaluateJS("""
+ this.select(document.querySelector('p').lastElementChild.firstChild, 1, 2);
+ """.trimIndent())
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ val node = createNodeInfo(getSourceId(event))
+ assertThat("Text node should match text", node.text as String, equalTo("world"))
+ }
+ })
+
+ mainSession.finder.find("sweet", 0)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ val node = createNodeInfo(getSourceId(event))
+ assertThat("Text node should match text", node.contentDescription as String, equalTo("sweet"))
+ }
+ })
+
+ // reset caret position
+ mainSession.evaluateJS("""
+ this.select(document.body, 0, 0);
+ """.trimIndent())
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) {}
+ })
+
+ mainSession.finder.find("Hell", 0)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ val node = createNodeInfo(getSourceId(event))
+ assertThat("Text node should match text", node.text as String, equalTo("Hello "))
+ }
+ })
+ }
+
+ private fun waitUntilTextSelectionChanged(fromIndex: Int, toIndex: Int) {
+ var eventFromIndex = 0;
+ var eventToIndex = 0;
+ do {
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ override fun onTextSelectionChanged(event: AccessibilityEvent) {
+ eventFromIndex = event.fromIndex;
+ eventToIndex = event.toIndex;
+ }
+ })
+ } while (fromIndex != eventFromIndex || toIndex != eventToIndex)
+ }
+
+ private fun waitUntilTextTraversed(fromIndex: Int, toIndex: Int,
+ expectedNode: Int? = null): Int {
+ var nodeId: Int = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onTextTraversal(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ if (expectedNode != null) {
+ assertThat("Node matches", nodeId, equalTo(expectedNode))
+ }
+ assertThat("fromIndex matches", event.fromIndex, equalTo(fromIndex))
+ assertThat("toIndex matches", event.toIndex, equalTo(toIndex))
+ }
+ })
+ return nodeId
+ }
+
+ private fun waitUntilClick(checked: Boolean) {
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onClicked(event: AccessibilityEvent) {
+ var nodeId = getSourceId(event)
+ var node = createNodeInfo(nodeId)
+ assertThat("Event's checked state matches", event.isChecked, equalTo(checked))
+ assertThat("Checkbox node has correct checked state", node.isChecked, equalTo(checked))
+ }
+ })
+ }
+
+ private fun waitUntilSelect(selected: Boolean) {
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onSelected(event: AccessibilityEvent) {
+ var nodeId = getSourceId(event)
+ var node = createNodeInfo(nodeId)
+ assertThat("Selectable node has correct selected state", node.isSelected, equalTo(selected))
+ }
+ })
+ }
+
+ private fun setSelectionArguments(start: Int, end: Int): Bundle {
+ val arguments = Bundle(2)
+ arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, start)
+ arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, end)
+ return arguments
+ }
+
+ private fun moveByGranularityArguments(granularity: Int, extendSelection: Boolean = false): Bundle {
+ val arguments = Bundle(2)
+ arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity)
+ arguments.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, extendSelection)
+ return arguments
+ }
+
+ @Test fun testClipboard() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID;
+ loadTestPage("test-clipboard")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('input').focus()")
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Focused EditBox", node.className.toString(),
+ equalTo("android.widget.EditText"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onTextSelectionChanged(event: AccessibilityEvent) {
+ assertThat("fromIndex should be at start", event.fromIndex, equalTo(0))
+ assertThat("toIndex should be at start", event.toIndex, equalTo(0))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(5, 11))
+ waitUntilTextSelectionChanged(5, 11)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_COPY, null)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(11, 11))
+ waitUntilTextSelectionChanged(11, 11)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PASTE, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onTextChanged(event: AccessibilityEvent) {
+ assertThat("text should be pasted", event.text[0].toString(), equalTo("hello cruel cruel world"))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(17, 23))
+ waitUntilTextSelectionChanged(17, 23)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PASTE, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled
+ override fun onTextChanged(event: AccessibilityEvent) {
+ assertThat("text should be pasted", event.text[0].toString(), equalTo("hello cruel cruel cruel"))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(0, 0))
+ waitUntilTextSelectionChanged(0, 0)
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD, true))
+ waitUntilTextSelectionChanged(0, 5)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CUT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled
+ override fun onTextChanged(event: AccessibilityEvent) {
+ assertThat("text should be cut", event.text[0].toString(), equalTo(" cruel cruel cruel"))
+ }
+ })
+ }
+
+ @Test fun testMoveByCharacter() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum"))
+ }
+ })
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ waitUntilTextTraversed(0, 1, nodeId) // "L"
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ waitUntilTextTraversed(1, 2, nodeId) // "o"
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ waitUntilTextTraversed(0, 1, nodeId) // "L"
+ }
+
+ @Test fun testMoveByWord() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum"))
+ }
+ })
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ waitUntilTextTraversed(0, 5, nodeId) // "Lorem"
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ waitUntilTextTraversed(6, 11, nodeId) // "ipsum"
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ waitUntilTextTraversed(0, 5, nodeId) // "Lorem"
+ }
+
+ @Test fun testMoveByLine() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum"))
+ }
+ })
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE))
+ waitUntilTextTraversed(0, 18, nodeId) // "Lorem ipsum dolor "
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE))
+ waitUntilTextTraversed(18, 28, nodeId) // "sit amet, "
+
+ provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE))
+ waitUntilTextTraversed(0, 18, nodeId) // "Lorem ipsum dolor "
+ }
+
+ @Test fun testMoveByCharacterAtEdges() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ waitForInitialFocus()
+
+ // Move to the first link containing "anim id".
+ val bundle = Bundle()
+ bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK")
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on link", node.contentDescription as String, startsWith("anim id"))
+ }
+ })
+
+ var success: Boolean
+ // Navigate forward through "anim id" character by character.
+ for (start in 0..6) {
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ assertThat("Next char should succeed", success, equalTo(true))
+ waitUntilTextTraversed(start, start + 1, nodeId)
+ }
+
+ // Try to navigate forward past end.
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ assertThat("Next char should fail at end", success, equalTo(false))
+
+ // We're already on "d". Navigate backward through "anim i".
+ for (start in 5 downTo 0) {
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ assertThat("Prev char should succeed", success, equalTo(true))
+ waitUntilTextTraversed(start, start + 1, nodeId)
+ }
+
+ // Try to navigate backward past start.
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ assertThat("Prev char should fail at start", success, equalTo(false))
+ }
+
+ @Test fun testMoveByWordAtEdges() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ waitForInitialFocus()
+
+ // Move to the first link containing "anim id".
+ val bundle = Bundle()
+ bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK")
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on link", node.contentDescription as String, startsWith("anim id"))
+ }
+ })
+
+ var success: Boolean
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ assertThat("Next word should succeed", success, equalTo(true))
+ waitUntilTextTraversed(0, 4, nodeId) // "anim"
+
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ assertThat("Next word should succeed", success, equalTo(true))
+ waitUntilTextTraversed(5, 7, nodeId) // "id"
+
+ // Try to navigate forward past end.
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ assertThat("Next word should fail at end", success, equalTo(false))
+
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ assertThat("Prev word should succeed", success, equalTo(true))
+ waitUntilTextTraversed(0, 4, nodeId) // "anim"
+
+ // Try to navigate backward past start.
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ assertThat("Prev word should fail at start", success, equalTo(false))
+ }
+
+ @Test fun testMoveAtEndOfTextTrailingWhitespace() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum"))
+ }
+ })
+
+ // Initial move backward to move to last word.
+ var success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ assertThat("Prev word should succeed", success, equalTo(true))
+ waitUntilTextTraversed(418, 424, nodeId) // "mollit"
+
+ // Try to move forward past last word.
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
+ assertThat("Next word should fail at last word", success, equalTo(false))
+
+ // Move forward by character (onto trailing space).
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ assertThat("Next char should succeed", success, equalTo(true))
+ waitUntilTextTraversed(424, 425, nodeId) // " "
+
+ // Try to move forward past last character.
+ success = provider.performAction(nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
+ assertThat("Next char should fail at last char", success, equalTo(false))
+ }
+
+ @Test fun testHeadings() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID;
+ loadTestPage("test-headings")
+ waitForInitialFocus()
+
+ val bundle = Bundle()
+ bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "HEADING")
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on first heading", node.contentDescription as String, startsWith("Fried cheese"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("First heading is level 1",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("heading level 1"))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on second heading", node.contentDescription as String, startsWith("Popcorn shrimp"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Second heading is level 2",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("heading level 2"))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on second heading", node.contentDescription as String, startsWith("Chicken fingers"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Third heading is level 3",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("heading level 3"))
+ }
+ }
+ })
+ }
+
+ @Test fun testCheckbox() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID;
+ loadTestPage("test-checkbox")
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ var node = createNodeInfo(nodeId)
+ assertThat("Checkbox node is checkable", node.isCheckable, equalTo(true))
+ assertThat("Checkbox node is clickable", node.isClickable, equalTo(true))
+ assertThat("Checkbox node is focusable", node.isFocusable, equalTo(true))
+ assertThat("Checkbox node is not checked", node.isChecked, equalTo(false))
+ assertThat("Checkbox node has correct role", node.text.toString(), equalTo("many option"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Hint has description", node.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("description"))
+ }
+
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null)
+ waitUntilClick(true)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null)
+ waitUntilClick(false)
+ }
+
+ @Test fun testExpandable() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID;
+ loadTestPage("test-expandable")
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ if (Build.VERSION.SDK_INT >= 21) {
+ val node = createNodeInfo(nodeId)
+ assertThat("button is expandable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND))
+ assertThat("button is not collapsable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE)))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_EXPAND, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onClicked(event: AccessibilityEvent) {
+ assertThat("Clicked event is from same node", getSourceId(event), equalTo(nodeId))
+ if (Build.VERSION.SDK_INT >= 21) {
+ val node = createNodeInfo(nodeId)
+ assertThat("button is collapsable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE))
+ assertThat("button is not expandable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_COLLAPSE, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onClicked(event: AccessibilityEvent) {
+ assertThat("Clicked event is from same node", getSourceId(event), equalTo(nodeId))
+ if (Build.VERSION.SDK_INT >= 21) {
+ val node = createNodeInfo(nodeId)
+ assertThat("button is expandable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND))
+ assertThat("button is not collapsable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE)))
+ }
+ }
+ })
+ }
+
+ @Test fun testSelectable() {
+ var nodeId = View.NO_ID
+ loadTestPage("test-selectable")
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ var node = createNodeInfo(nodeId)
+ assertThat("Selectable node is clickable", node.isClickable, equalTo(true))
+ assertThat("Selectable node is not selected", node.isSelected, equalTo(false))
+ assertThat("Selectable node has correct text", node.text.toString(), equalTo("1"))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null)
+ waitUntilSelect(true)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null)
+ waitUntilSelect(false)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SELECT, null)
+ waitUntilSelect(true)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SELECT, null)
+ waitUntilSelect(false)
+ }
+
+ @Test fun testMutation() {
+ loadTestPage("test-mutation")
+ waitForInitialFocus()
+
+ val rootNode = createNodeInfo(View.NO_ID)
+ assertThat("Document has 1 child", rootNode.childCount, equalTo(1))
+
+ assertThat("Section has 1 child",
+ createNodeInfo(rootNode.getChildId(0)).childCount, equalTo(1))
+ mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'none';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 0)
+ override fun onAnnouncement(event: AccessibilityEvent) { }
+
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+
+ assertThat("Section has no children",
+ createNodeInfo(rootNode.getChildId(0)).childCount, equalTo(0))
+ }
+
+ @Test fun testLiveRegion() {
+ loadTestPage("test-live-region")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('#to_change').textContent = 'Hello';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAnnouncement(event: AccessibilityEvent) {
+ assertThat("Announcement is correct", event.text[0].toString(), equalTo("Hello"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+ }
+
+ @Test fun testLiveRegionDescendant() {
+ loadTestPage("test-live-region-descendant")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'none';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 0)
+ override fun onAnnouncement(event: AccessibilityEvent) { }
+
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'block';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAnnouncement(event: AccessibilityEvent) {
+ assertThat("Announcement is correct", event.text[0].toString(), equalTo("I will be shown"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+ }
+
+ @Test fun testLiveRegionAtomic() {
+ loadTestPage("test-live-region-atomic")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('p').textContent = '4pm';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAnnouncement(event: AccessibilityEvent) {
+ assertThat("Announcement is correct", event.text[0].toString(), equalTo("The time is 4pm"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#container').removeAttribute('aria-atomic');" +
+ "document.querySelector('p').textContent = '5pm';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAnnouncement(event: AccessibilityEvent) {
+ assertThat("Announcement is correct", event.text[0].toString(), equalTo("5pm"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+ }
+
+ @Test fun testLiveRegionImage() {
+ loadTestPage("test-live-region-image")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('img').alt = 'sad';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAnnouncement(event: AccessibilityEvent) {
+ assertThat("Announcement is correct", event.text[0].toString(), equalTo("This picture is sad"))
+ }
+ })
+ }
+
+ @Test fun testLiveRegionImageLabeledBy() {
+ loadTestPage("test-live-region-image-labeled-by")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('img').setAttribute('aria-labelledby', 'l2');")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAnnouncement(event: AccessibilityEvent) {
+ assertThat("Announcement is correct", event.text[0].toString(), equalTo("Goodbye"))
+ }
+ })
+ }
+
+ private fun screenContainsNode(nodeId: Int): Boolean {
+ var node = createNodeInfo(nodeId)
+ var nodeBounds = Rect()
+ node.getBoundsInScreen(nodeBounds)
+ return screenRect.contains(nodeBounds)
+ }
+
+ @Ignore // Bug 1506276 - We need to reliably wait for APZC here, and it's not trivial.
+ @Test fun testScroll() {
+ var nodeId = View.NO_ID
+ loadTestPage("test-scroll.html")
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled
+ override fun onWinStateChanged(event: AccessibilityEvent) { }
+
+ @AssertCalled(count = 1)
+ @Suppress("deprecation")
+ override fun onFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ var node = createNodeInfo(nodeId)
+ var nodeBounds = Rect()
+ node.getBoundsInParent(nodeBounds)
+ assertThat("Default root node bounds are correct", nodeBounds, equalTo(screenRect))
+ }
+ })
+
+ provider.performAction(View.NO_ID, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onScrolled(event: AccessibilityEvent) {
+ assertThat("View is scrolled for focused node to be onscreen", event.scrollY, greaterThan(0))
+ assertThat("View is not scrolled to the end", event.scrollY, lessThan(event.maxScrollY))
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onWinContentChanged(event: AccessibilityEvent) {
+ assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true))
+ }
+ })
+
+ SystemClock.sleep(100);
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onScrolled(event: AccessibilityEvent) {
+ assertThat("View is scrolled to the end", event.scrollY.toDouble(), closeTo(event.maxScrollY.toDouble(), 1.0))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onWinContentChanged(event: AccessibilityEvent) {
+ assertThat("Focused node is still onscreen", screenContainsNode(nodeId), equalTo(true))
+ }
+ })
+
+ SystemClock.sleep(100)
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onScrolled(event: AccessibilityEvent) {
+ assertThat("View is scrolled to the beginning", event.scrollY, equalTo(0))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onWinContentChanged(event: AccessibilityEvent) {
+ assertThat("Focused node is offscreen", screenContainsNode(nodeId), equalTo(false))
+ }
+ })
+
+ SystemClock.sleep(100)
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onScrolled(event: AccessibilityEvent) {
+ assertThat("View is scrolled to the end", event.scrollY.toDouble(), closeTo(event.maxScrollY.toDouble(), 1.0))
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onWinContentChanged(event: AccessibilityEvent) {
+ assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true))
+ }
+ })
+ }
+
+ @Setting(key = Setting.Key.FULL_ACCESSIBILITY_TREE, value = "true")
+ @Test fun autoFill() {
+ // Wait for the accessibility nodes to populate.
+ mainSession.loadTestPath(FORMS_HTML_PATH)
+ waitForInitialFocus()
+
+ val autoFills = mapOf(
+ "#user1" to "bar", "#pass1" to "baz", "#user2" to "bar", "#pass2" to "baz") +
+ if (Build.VERSION.SDK_INT >= 19) mapOf(
+ "#email1" to "a@b.c", "#number1" to "24", "#tel1" to "42")
+ else mapOf(
+ "#email1" to "bar", "#number1" to "", "#tel1" to "bar")
+
+ // Set up promises to monitor the values changing.
+ val promises = autoFills.flatMap { entry ->
+ // Repeat each test with both the top document and the iframe document.
+ arrayOf("document", "document.querySelector('#iframe').contentDocument").map { doc ->
+ mainSession.evaluatePromiseJS("""new Promise(resolve =>
+ $doc.querySelector('${entry.key}').addEventListener(
+ 'input', event => {
+ let eventInterface =
+ event instanceof InputEvent ? "InputEvent" :
+ event instanceof UIEvent ? "UIEvent" :
+ event instanceof Event ? "Event" : "Unknown";
+ resolve([event.target.value, '${entry.value}', eventInterface]);
+ }, { once: true }))""")
+ }
+ }
+
+ // Perform auto-fill and return number of auto-fills performed.
+ fun autoFillChild(id: Int, child: AccessibilityNodeInfo) {
+ // Seal the node info instance so we can perform actions on it.
+ if (child.childCount > 0) {
+ for (i in 0 until child.childCount) {
+ val childId = child.getChildId(i)
+ autoFillChild(childId, createNodeInfo(childId))
+ }
+ }
+
+ if (EditText::class.java.name == child.className) {
+ assertThat("Input should be enabled", child.isEnabled, equalTo(true))
+ assertThat("Input should be focusable", child.isFocusable, equalTo(true))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Password type should match", child.isPassword, equalTo(
+ child.inputType == InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD))
+ }
+
+ val args = Bundle(1)
+ val value = if (child.isPassword) "baz" else
+ if (Build.VERSION.SDK_INT < 19) "bar" else
+ when (child.inputType) {
+ InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS -> "a@b.c"
+ InputType.TYPE_CLASS_NUMBER -> "24"
+ InputType.TYPE_CLASS_PHONE -> "42"
+ else -> "bar"
+ }
+
+ val ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE = if (Build.VERSION.SDK_INT >= 21)
+ AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE else
+ "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE"
+ val ACTION_SET_TEXT = if (Build.VERSION.SDK_INT >= 21)
+ AccessibilityNodeInfo.ACTION_SET_TEXT else 0x200000
+
+ args.putCharSequence(ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, value)
+ assertThat("Can perform auto-fill",
+ provider.performAction(id, ACTION_SET_TEXT, args), equalTo(true))
+ }
+ }
+
+ autoFillChild(View.NO_ID, createNodeInfo(View.NO_ID))
+
+ // Wait on the promises and check for correct values.
+ for ((actual, expected, eventInterface) in promises.map { it.value.asJSList<String>() }) {
+ assertThat("Auto-filled value must match", actual, equalTo(expected))
+ assertThat("input event should be dispatched with InputEvent interface", eventInterface, equalTo("InputEvent"))
+ }
+ }
+
+ @Setting(key = Setting.Key.FULL_ACCESSIBILITY_TREE, value = "true")
+ @Test fun autoFill_navigation() {
+ // disable test on debug for frequently failing #Bug 1505353
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(false))
+ fun countAutoFillNodes(cond: (AccessibilityNodeInfo) -> Boolean =
+ { it.className == "android.widget.EditText" },
+ id: Int = View.NO_ID): Int {
+ val info = createNodeInfo(id)
+ return (if (cond(info) && info.className != "android.webkit.WebView" ) 1 else 0) + (if (info.childCount > 0)
+ (0 until info.childCount).sumBy {
+ countAutoFillNodes(cond, info.getChildId(it))
+ } else 0)
+ }
+
+ // Wait for the accessibility nodes to populate.
+ mainSession.loadTestPath(FORMS_HTML_PATH)
+ waitForInitialFocus()
+
+ assertThat("Initial auto-fill count should match",
+ countAutoFillNodes(), equalTo(14))
+ assertThat("Password auto-fill count should match",
+ countAutoFillNodes({ it.isPassword }), equalTo(4))
+
+ // Now wait for the nodes to clear.
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ waitForInitialFocus()
+ assertThat("Should not have auto-fill fields",
+ countAutoFillNodes(), equalTo(0))
+
+ // Now wait for the nodes to reappear.
+ mainSession.goBack()
+ waitForInitialFocus()
+ assertThat("Should have auto-fill fields again",
+ countAutoFillNodes(), equalTo(14))
+ assertThat("Should not have focused field",
+ countAutoFillNodes({ it.isFocused }), equalTo(0))
+
+ mainSession.evaluateJS("document.querySelector('#pass1').focus()")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled
+ override fun onFocused(event: AccessibilityEvent) {
+ }
+ })
+ assertThat("Should have one focused field",
+ countAutoFillNodes({ it.isFocused }), equalTo(1))
+
+ mainSession.evaluateJS("document.querySelector('#pass1').blur()")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled
+ override fun onFocused(event: AccessibilityEvent) {
+ }
+ })
+ assertThat("Should not have focused field",
+ countAutoFillNodes({ it.isFocused }), equalTo(0))
+ }
+
+ @Setting(key = Setting.Key.FULL_ACCESSIBILITY_TREE, value = "true")
+ @Test fun testTree() {
+ loadTestPage("test-tree")
+ waitForInitialFocus()
+
+ val rootNode = createNodeInfo(View.NO_ID)
+ assertThat("Document has 3 children", rootNode.childCount, equalTo(3))
+
+ val labelNode = createNodeInfo(rootNode.getChildId(0))
+ assertThat("First node is a label", labelNode.className.toString(), equalTo("android.view.View"))
+ assertThat("Label has text", labelNode.text.toString(), equalTo("Name:"))
+
+ val entryNode = createNodeInfo(rootNode.getChildId(1))
+ assertThat("Second node is an entry", entryNode.className.toString(), equalTo("android.widget.EditText"))
+ assertThat("Entry has vieIdwResourceName of 'name'", entryNode.viewIdResourceName, equalTo("name"))
+ assertThat("Entry value is text", entryNode.text.toString(), equalTo("Julie"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Entry hint is label",
+ entryNode.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("Name:"))
+ assertThat("Entry input type is correct", entryNode.inputType,
+ equalTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT))
+ }
+
+ val buttonNode = createNodeInfo(rootNode.getChildId(2))
+ assertThat("Last node is a button", buttonNode.className.toString(), equalTo("android.widget.Button"))
+ // The child text leaf is pruned, so this button is childless.
+ assertThat("Button has a single text leaf", buttonNode.childCount, equalTo(0))
+ assertThat("Button has correct text", buttonNode.text.toString(), equalTo("Submit"))
+ }
+
+ @Setting(key = Setting.Key.FULL_ACCESSIBILITY_TREE, value = "true")
+ @Test fun testCollection() {
+ loadTestPage("test-collection")
+ waitForInitialFocus()
+
+ val rootNode = createNodeInfo(View.NO_ID)
+ assertThat("Document has 2 children", rootNode.childCount, equalTo(2))
+
+ val firstList = createNodeInfo(rootNode.getChildId(0))
+ assertThat("First list has 2 children", firstList.childCount, equalTo(2))
+ assertThat("List is a ListView", firstList.className.toString(), equalTo("android.widget.ListView"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("First list should have collectionInfo", firstList.collectionInfo, notNullValue())
+ assertThat("First list has 2 rowCount", firstList.collectionInfo.rowCount, equalTo(2))
+ assertThat("First list should not be hierarchical", firstList.collectionInfo.isHierarchical, equalTo(false))
+ }
+
+ val firstListFirstItem = createNodeInfo(firstList.getChildId(0))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Item has collectionItemInfo", firstListFirstItem.collectionItemInfo, notNullValue())
+ assertThat("Item has collectionItemInfo", firstListFirstItem.collectionItemInfo.rowIndex, equalTo(1))
+ }
+
+ val secondList = createNodeInfo(rootNode.getChildId(1))
+ assertThat("Second list has 1 child", secondList.childCount, equalTo(1))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Second list should have collectionInfo", secondList.collectionInfo, notNullValue())
+ assertThat("Second list has 2 rowCount", secondList.collectionInfo.rowCount, equalTo(1))
+ assertThat("Second list should be hierarchical", secondList.collectionInfo.isHierarchical, equalTo(true))
+ }
+ }
+
+ @Setting(key = Setting.Key.FULL_ACCESSIBILITY_TREE, value = "true")
+ @Test fun testRange() {
+ loadTestPage("test-range")
+ waitForInitialFocus()
+
+ val rootNode = createNodeInfo(View.NO_ID)
+ assertThat("Document has 3 children", rootNode.childCount, equalTo(3))
+
+ val firstRange = createNodeInfo(rootNode.getChildId(0))
+ assertThat("Range has right label", firstRange.text.toString(), equalTo("Rating"))
+ assertThat("Range is SeekBar", firstRange.className.toString(), equalTo("android.widget.SeekBar"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("'Rating' has rangeInfo", firstRange.rangeInfo, notNullValue())
+ assertThat("'Rating' has correct value", firstRange.rangeInfo.current, equalTo(4f))
+ assertThat("'Rating' has correct max", firstRange.rangeInfo.max, equalTo(10f))
+ assertThat("'Rating' has correct min", firstRange.rangeInfo.min, equalTo(1f))
+ assertThat("'Rating' has correct range type", firstRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_INT))
+ }
+
+ val secondRange = createNodeInfo(rootNode.getChildId(1))
+ assertThat("Range has right label", secondRange.text.toString(), equalTo("Stars"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("'Rating' has rangeInfo", secondRange.rangeInfo, notNullValue())
+ assertThat("'Rating' has correct value", secondRange.rangeInfo.current, equalTo(4.5f))
+ assertThat("'Rating' has correct max", secondRange.rangeInfo.max, equalTo(5f))
+ assertThat("'Rating' has correct min", secondRange.rangeInfo.min, equalTo(1f))
+ assertThat("'Rating' has correct range type", secondRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT))
+ }
+
+ val thirdRange = createNodeInfo(rootNode.getChildId(2))
+ assertThat("Range has right label", thirdRange.text.toString(), equalTo("Percent"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("'Rating' has rangeInfo", thirdRange.rangeInfo, notNullValue())
+ assertThat("'Rating' has correct value", thirdRange.rangeInfo.current, equalTo(0.83f))
+ assertThat("'Rating' has correct max", thirdRange.rangeInfo.max, equalTo(1f))
+ assertThat("'Rating' has correct min", thirdRange.rangeInfo.min, equalTo(0f))
+ assertThat("'Rating' has correct range type", thirdRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_PERCENT))
+ }
+ }
+
+ @Test fun testLinksMovingByDefault() {
+ loadTestPage("test-links")
+ waitForInitialFocus()
+ var nodeId = View.NO_ID;
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with href",
+ node.contentDescription as String, startsWith("a with href"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("a with href is a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("link"))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with no attributes",
+ node.text as String, startsWith("a with no attributes"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("a with no attributes is not a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo(""))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with name",
+ node.text as String, startsWith("a with name"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("a with name is not a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo(""))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with onclick",
+ node.contentDescription as String, startsWith("a with onclick"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("a with onclick is a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("link"))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on span with role link",
+ node.contentDescription as String, startsWith("span with role link"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("span with role link is a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("link"))
+ }
+ }
+ })
+ }
+
+ @Test fun testLinksMovingByLink() {
+ loadTestPage("test-links")
+ waitForInitialFocus()
+ var nodeId = View.NO_ID;
+
+ val bundle = Bundle()
+ bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK")
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with href",
+ node.contentDescription as String, startsWith("a with href"))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with onclick",
+ node.contentDescription as String, startsWith("a with onclick"))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on span with role link",
+ node.contentDescription as String, startsWith("span with role link"))
+ }
+ })
+ }
+
+ @Test fun testAriaComboBoxesMovingByDefault() {
+ loadTestPage("test-aria-comboboxes")
+ waitForInitialFocus()
+ var nodeId = View.NO_ID;
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus is EditBox",
+ node.className.toString(),
+ equalTo("android.widget.EditText"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Accessibility focus on ARIA 1.0 combobox",
+ node.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("ARIA 1.0 combobox"))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus is EditBox",
+ node.className.toString(),
+ equalTo("android.widget.EditText"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Accessibility focus on ARIA 1.1 combobox",
+ node.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("ARIA 1.1 combobox"))
+ }
+ }
+ })
+ }
+
+ @Test fun testAriaComboBoxesMovingByControl() {
+ loadTestPage("test-aria-comboboxes")
+ waitForInitialFocus()
+ var nodeId = View.NO_ID;
+
+ val bundle = Bundle()
+ bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "CONTROL")
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus is EditBox",
+ node.className.toString(),
+ equalTo("android.widget.EditText"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Accessibility focus on ARIA 1.0 combobox",
+ node.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("ARIA 1.0 combobox"))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus is EditBox",
+ node.className.toString(),
+ equalTo("android.widget.EditText"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("Accessibility focus on ARIA 1.1 combobox",
+ node.extras.getString("AccessibilityNodeInfo.hint"),
+ equalTo("ARIA 1.1 combobox"))
+ }
+ }
+ })
+ }
+
+ @Test fun testAccessibilityFocusBoundaries() {
+ loadTestPage("test-links")
+ waitForInitialFocus()
+ var nodeId = View.NO_ID
+ var performedAction: Boolean
+
+ performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ assertThat("Successfully moved a11y focus to first node", performedAction, equalTo(true))
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with href",
+ node.contentDescription as String, startsWith("a with href"))
+ }
+ })
+
+ performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null)
+ assertThat("Successfully moved a11y focus past first node", performedAction, equalTo(true))
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ assertThat("Accessibility focus on web view", getSourceId(event), equalTo(View.NO_ID))
+ }
+ })
+
+ performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ assertThat("Successfully moved a11y focus to second node", performedAction, equalTo(true))
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with no attributes",
+ node.text as String, startsWith("a with no attributes"))
+ }
+ })
+
+ // hide first and last link
+ mainSession.evaluateJS("document.querySelectorAll('body > :first-child, body > :last-child').forEach(e => e.style.display = 'none');")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+
+ performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null)
+ assertThat("Successfully moved a11y focus past first visible node", performedAction, equalTo(true))
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ assertThat("Accessibility focus on web view", getSourceId(event), equalTo(View.NO_ID))
+ }
+ })
+
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with name",
+ node.text as String, startsWith("a with name"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("a with name is not a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo(""))
+ }
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on a with onclick",
+ node.contentDescription as String, startsWith("a with onclick"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("a with onclick is a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("link"))
+ }
+ }
+ })
+
+ performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ assertThat("Should fail to move a11y focus to last hidden node", performedAction, equalTo(false))
+
+ // show last link
+ mainSession.evaluateJS("document.querySelector('body > :last-child').style.display = 'initial';")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Accessibility focus on span with role link",
+ node.contentDescription as String, startsWith("span with role link"))
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat("span with role link is a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(),
+ equalTo("link"))
+ }
+ }
+ })
+
+ performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null)
+ assertThat("Should fail to move a11y focus beyond last node", performedAction, equalTo(false))
+
+ performedAction = provider.performAction(View.NO_ID, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null)
+ assertThat("Should fail to move a11y focus before web content", performedAction, equalTo(false))
+ }
+
+ @Test fun testTextEntry() {
+ loadTestPage("test-text-entry-node")
+ waitForInitialFocus()
+
+ mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').focus()")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) {}
+ })
+
+ mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').value = 'Tobiasas'")
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onTextChanged(event: AccessibilityEvent) {}
+
+ @AssertCalled(count = 1)
+ override fun onTextSelectionChanged(event: AccessibilityEvent) {}
+
+ // Don't fire a11y focus for collapsed caret changes.
+ // This will interfere with on screen keyboards and throw a11y focus
+ // back and fourth.
+ @AssertCalled(count = 0)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {}
+ })
+ }
+
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt
new file mode 100644
index 0000000000..5f2c8d591d
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt
@@ -0,0 +1,1334 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import android.os.Handler
+import android.view.KeyEvent
+
+import org.hamcrest.Matchers.*
+
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.PromptDelegate
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest
+import org.mozilla.geckoview.Autocomplete
+import org.mozilla.geckoview.Autocomplete.LoginEntry
+import org.mozilla.geckoview.Autocomplete.LoginSaveOption
+import org.mozilla.geckoview.Autocomplete.LoginSelectOption
+import org.mozilla.geckoview.Autocomplete.LoginStorageDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class AutocompleteTest : BaseSessionTest() {
+ val acceptDelay: Long = 100
+
+ @Test
+ fun fetchLogins() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true))
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ val fetchHandled = GeckoResult<Void>()
+
+ sessionRule.addExternalDelegateDuringNextWait(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ Handler().postDelayed({
+ fetchHandled.complete(null)
+ }, acceptDelay)
+
+ return null
+ }
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ sessionRule.waitForResult(fetchHandled)
+ }
+
+ @Test
+ fun loginSaveDismiss() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ sessionRule.addExternalDelegateDuringNextWait(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ return null
+ }
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled(count = 0)
+ override fun onLoginSave(login: LoginEntry) {}
+ })
+
+ // Assign login credentials.
+ mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'")
+ mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'")
+
+ // Submit the form.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Login should not be null", login, notNullValue())
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo("user1x"))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo("pass1x"))
+
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @Test
+ fun loginSaveAccept() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled = GeckoResult<Void>()
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginSave(login: LoginEntry) {
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo("user1x"))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo("pass1x"))
+
+ saveHandled.complete(null)
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo("user1x"))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo("pass1x"))
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+ })
+
+ // Assign login credentials.
+ mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'")
+ mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'")
+
+ // Submit the form.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitForResult(saveHandled)
+ }
+
+ @Test
+ fun loginSaveModifyAccept() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled = GeckoResult<Void>()
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginSave(login: LoginEntry) {
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo("user1x"))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo("pass1xmod"))
+
+ saveHandled.complete(null)
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo("user1x"))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo("pass1x"))
+
+ val modLogin = LoginEntry.Builder()
+ .origin(login.origin)
+ .formActionOrigin(login.origin)
+ .httpRealm(login.httpRealm)
+ .username(login.username)
+ .password("pass1xmod")
+ .build()
+
+ return GeckoResult.fromValue(prompt.confirm(LoginSaveOption(modLogin)))
+ }
+ })
+
+ // Assign login credentials.
+ mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'")
+ mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'")
+
+ // Submit the form.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitForResult(saveHandled)
+ }
+
+ @Test
+ fun loginUpdateAccept() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ val saveHandled = GeckoResult<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+
+ val user1 = "user1x"
+ val pass1 = "pass1x"
+ val pass2 = "pass1up"
+ val guid = "test-guid"
+ val savedLogins = mutableListOf<LoginEntry>()
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ return GeckoResult.fromValue(savedLogins.toTypedArray())
+ }
+
+ @AssertCalled(count = 2)
+ override fun onLoginSave(login: LoginEntry) {
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(forEachCall(pass1, pass2)))
+
+ assertThat(
+ "GUID should match",
+ login.guid,
+ equalTo(forEachCall(null, guid)))
+
+ val savedLogin = LoginEntry.Builder()
+ .guid(guid)
+ .origin(login.origin)
+ .formActionOrigin(login.formActionOrigin)
+ .username(login.username)
+ .password(login.password)
+ .build()
+
+ savedLogins.add(savedLogin)
+
+ if (sessionRule.currentCall.counter == 1) {
+ saveHandled.complete(null)
+ } else if (sessionRule.currentCall.counter == 2) {
+ saveHandled2.complete(null)
+ }
+ }
+ })
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(forEachCall(pass1, pass2)))
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+ })
+
+ // Assign login credentials.
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'")
+ mainSession.evaluateJS("document.querySelector('#pass1').value = '$pass1'")
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitForResult(saveHandled)
+
+ // Update login credentials.
+ val session2 = sessionRule.createOpenSession()
+ session2.loadTestPath(FORMS3_HTML_PATH)
+ session2.waitForPageStop()
+ session2.evaluateJS("document.querySelector('#pass1').value = '$pass2'")
+ session2.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitForResult(saveHandled2)
+ }
+
+ fun testLoginUsed(autofillEnabled: Boolean) {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ val usedHandled = GeckoResult<Void>()
+
+ val user1 = "user1x"
+ val pass1 = "pass1x"
+ val guid = "test-guid"
+ val origin = GeckoSessionTestRule.TEST_ENDPOINT
+ val savedLogin = LoginEntry.Builder()
+ .guid(guid)
+ .origin(origin)
+ .formActionOrigin(origin)
+ .username(user1)
+ .password(pass1)
+ .build()
+ val savedLogins = mutableListOf<LoginEntry>(savedLogin)
+
+ if (autofillEnabled) {
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ return GeckoResult.fromValue(savedLogins.toTypedArray())
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLoginUsed(login: LoginEntry, usedFields: Int) {
+ assertThat(
+ "Used fields should match",
+ usedFields,
+ equalTo(Autocomplete.UsedField.PASSWORD))
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(pass1))
+
+ assertThat(
+ "GUID should match",
+ login.guid,
+ equalTo(guid))
+
+ usedHandled.complete(null)
+ }
+ })
+ } else {
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ return GeckoResult.fromValue(savedLogins.toTypedArray())
+ }
+
+ @AssertCalled(false)
+ override fun onLoginUsed(login: LoginEntry, usedFields: Int) {}
+ })
+ }
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(false)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ return null
+ }
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ if (autofillEnabled) {
+ sessionRule.waitForResult(usedHandled)
+ } else {
+ mainSession.waitForPageStop()
+ }
+ }
+
+ @Test
+ fun loginUsed() {
+ testLoginUsed(true)
+ }
+
+ @Test
+ fun loginAutofillDisabled() {
+ sessionRule.runtime.settings.loginAutofillEnabled = false
+ testLoginUsed(false)
+ sessionRule.runtime.settings.loginAutofillEnabled = true
+ }
+
+ fun testPasswordAutofill(autofillEnabled: Boolean) {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ val user1 = "user1x"
+ val pass1 = "pass1x"
+ val guid = "test-guid"
+ val origin = GeckoSessionTestRule.TEST_ENDPOINT
+ val savedLogin = LoginEntry.Builder()
+ .guid(guid)
+ .origin(origin)
+ .formActionOrigin(origin)
+ .username(user1)
+ .password(pass1)
+ .build()
+ val savedLogins = mutableListOf<LoginEntry>(savedLogin)
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ return GeckoResult.fromValue(savedLogins.toTypedArray())
+ }
+
+ @AssertCalled(false)
+ override fun onLoginUsed(login: LoginEntry, usedFields: Int) {}
+ })
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(false)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ return null
+ }
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("document.querySelector('#user1').focus()")
+ mainSession.evaluateJS(
+ "document.querySelector('#user1').value = '$user1'")
+ mainSession.pressKey(KeyEvent.KEYCODE_TAB)
+
+ val pass = mainSession.evaluateJS(
+ "document.querySelector('#pass1').value") as String
+
+ if (autofillEnabled) {
+ assertThat(
+ "Password should match",
+ pass,
+ equalTo(pass1))
+ } else {
+ assertThat(
+ "Password should not be filled",
+ pass,
+ equalTo(""))
+ }
+ }
+
+ @Test
+ fun loginAutofillDisabledPasswordAutofill() {
+ sessionRule.runtime.settings.loginAutofillEnabled = false
+ testPasswordAutofill(false)
+ sessionRule.runtime.settings.loginAutofillEnabled = true
+ }
+
+ @Test
+ fun loginAutofillEnabledPasswordAutofill() {
+ testPasswordAutofill(true)
+ }
+
+ @Test
+ fun loginSelectAccept() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "dom.disable_open_during_load" to false,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ // Test:
+ // 1. Load a login form page.
+ // 2. Input un/pw and submit.
+ // a. Ensure onLoginSave is called accordingly.
+ // b. Save the submitted login entry.
+ // 3. Reload the login form page.
+ // a. Ensure onLoginFetch is called.
+ // b. Return empty login entry list to avoid autofilling.
+ // 4. Input a new set of un/pw and submit.
+ // a. Ensure onLoginSave is called again.
+ // b. Save the submitted login entry.
+ // 5. Reload the login form page.
+ // 6. Focus on the username input field.
+ // a. Ensure onLoginFetch is called.
+ // b. Return the saved login entries.
+ // c. Ensure onLoginSelect is called.
+ // d. Select and return one of the options.
+ // e. Submit the form.
+ // f. Ensure that onLoginUsed is called.
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ val user1 = "user1x"
+ val user2 = "user2x"
+ val pass1 = "pass1x"
+ val pass2 = "pass2x"
+ val savedLogins = mutableListOf<LoginEntry>()
+
+ val saveHandled1 = GeckoResult<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+ val selectHandled = GeckoResult<Void>()
+ val usedHandled = GeckoResult<Void>()
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ var logins = mutableListOf<LoginEntry>()
+
+ if (savedLogins.size == 2) {
+ logins = savedLogins
+ }
+
+ return GeckoResult.fromValue(logins.toTypedArray())
+ }
+
+ @AssertCalled(count = 2)
+ override fun onLoginSave(login: LoginEntry) {
+ var username = ""
+ var password = ""
+ var handle = GeckoResult<Void>()
+
+ if (sessionRule.currentCall.counter == 1) {
+ username = user1
+ password = pass1
+ handle = saveHandled1
+ } else if (sessionRule.currentCall.counter == 2) {
+ username = user2
+ password = pass2
+ handle = saveHandled2
+ }
+
+ val savedLogin = LoginEntry.Builder()
+ .guid(login.username)
+ .origin(login.origin)
+ .formActionOrigin(login.formActionOrigin)
+ .username(login.username)
+ .password(login.password)
+ .build()
+
+ savedLogins.add(savedLogin)
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(username))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(password))
+
+ handle.complete(null)
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLoginUsed(login: LoginEntry, usedFields: Int) {
+ assertThat(
+ "Used fields should match",
+ usedFields,
+ equalTo(Autocomplete.UsedField.PASSWORD))
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(pass1))
+
+ assertThat(
+ "GUID should match",
+ login.guid,
+ equalTo(user1))
+
+ usedHandled.complete(null)
+ }
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(pass1))
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+ })
+
+ // Assign login credentials.
+ mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'")
+ mainSession.evaluateJS("document.querySelector('#pass1').value = '$pass1'")
+
+ // Submit the form.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+ sessionRule.waitForResult(saveHandled1)
+
+ // Reload.
+ val session2 = sessionRule.createOpenSession()
+ session2.loadTestPath(FORMS3_HTML_PATH)
+ session2.waitForPageStop()
+
+ session2.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user2))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(pass2))
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+ })
+
+ // Assign alternative login credentials.
+ session2.evaluateJS("document.querySelector('#user1').value = '$user2'")
+ session2.evaluateJS("document.querySelector('#pass1').value = '$pass2'")
+
+ // Submit the form.
+ session2.evaluateJS("document.querySelector('#form1').submit()")
+ sessionRule.waitForResult(saveHandled2)
+
+ // Reload for the last time.
+ val session3 = sessionRule.createOpenSession()
+
+ session3.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSelect(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSelectOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ assertThat(
+ "There should be two options",
+ prompt.options.size,
+ equalTo(2))
+
+ var usernames = arrayOf(user1, user2)
+ var passwords = arrayOf(pass1, pass2)
+
+ for (i in 0..1) {
+ val login = prompt.options[i].value
+
+ assertThat("Login should not be null", login, notNullValue())
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(usernames[i]))
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(passwords[i]))
+ }
+
+
+ Handler().postDelayed({
+ selectHandled.complete(null)
+ }, acceptDelay)
+
+ return GeckoResult.fromValue(prompt.confirm(prompt.options[0]))
+ }
+ })
+
+ session3.loadTestPath(FORMS3_HTML_PATH)
+ session3.waitForPageStop()
+
+ // Focus on the username input field.
+ session3.evaluateJS("document.querySelector('#user1').focus()")
+ sessionRule.waitForResult(selectHandled)
+
+ assertThat(
+ "Filled username should match",
+ session3.evaluateJS("document.querySelector('#user1').value") as String,
+ equalTo(user1))
+
+ assertThat(
+ "Filled password should match",
+ session3.evaluateJS("document.querySelector('#pass1').value") as String,
+ equalTo(pass1))
+
+ // Submit the selection.
+ session3.evaluateJS("document.querySelector('#form1').submit()")
+ sessionRule.waitForResult(usedHandled)
+ }
+
+ @Test
+ fun loginSelectModifyAccept() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "dom.disable_open_during_load" to false,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ // Test:
+ // 1. Load a login form page.
+ // 2. Input un/pw and submit.
+ // a. Ensure onLoginSave is called accordingly.
+ // b. Save the submitted login entry.
+ // 3. Reload the login form page.
+ // a. Ensure onLoginFetch is called.
+ // b. Return empty login entry list to avoid autofilling.
+ // 4. Input a new set of un/pw and submit.
+ // a. Ensure onLoginSave is called again.
+ // b. Save the submitted login entry.
+ // 5. Reload the login form page.
+ // 6. Focus on the username input field.
+ // a. Ensure onLoginFetch is called.
+ // b. Return the saved login entries.
+ // c. Ensure onLoginSelect is called.
+ // d. Select and return a new login entry.
+ // e. Submit the form.
+ // f. Ensure that onLoginUsed is not called.
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ val user1 = "user1x"
+ val user2 = "user2x"
+ val pass1 = "pass1x"
+ val pass2 = "pass2x"
+ val userMod = "user1xmod"
+ val passMod = "pass1xmod"
+ val savedLogins = mutableListOf<LoginEntry>()
+
+ val saveHandled1 = GeckoResult<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+ val selectHandled = GeckoResult<Void>()
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ var logins = mutableListOf<LoginEntry>()
+
+ if (savedLogins.size == 2) {
+ logins = savedLogins
+ }
+
+ return GeckoResult.fromValue(logins.toTypedArray())
+ }
+
+ @AssertCalled(count = 2)
+ override fun onLoginSave(login: LoginEntry) {
+ var username = ""
+ var password = ""
+ var handle = GeckoResult<Void>()
+
+ if (sessionRule.currentCall.counter == 1) {
+ username = user1
+ password = pass1
+ handle = saveHandled1
+ } else if (sessionRule.currentCall.counter == 2) {
+ username = user2
+ password = pass2
+ handle = saveHandled2
+ }
+
+ val savedLogin = LoginEntry.Builder()
+ .guid(login.username)
+ .origin(login.origin)
+ .formActionOrigin(login.formActionOrigin)
+ .username(login.username)
+ .password(login.password)
+ .build()
+
+ savedLogins.add(savedLogin)
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(username))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(password))
+
+ handle.complete(null)
+ }
+
+ @AssertCalled(false)
+ override fun onLoginUsed(login: LoginEntry, usedFields: Int) {}
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(pass1))
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+ })
+
+ // Assign login credentials.
+ mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'")
+ mainSession.evaluateJS("document.querySelector('#pass1').value = '$pass1'")
+
+ // Submit the form.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+ sessionRule.waitForResult(saveHandled1)
+
+ // Reload.
+ val session2 = sessionRule.createOpenSession()
+ session2.loadTestPath(FORMS3_HTML_PATH)
+ session2.waitForPageStop()
+
+ session2.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user2))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(pass2))
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+ })
+
+ // Assign alternative login credentials.
+ session2.evaluateJS("document.querySelector('#user1').value = '$user2'")
+ session2.evaluateJS("document.querySelector('#pass1').value = '$pass2'")
+
+ // Submit the form.
+ session2.evaluateJS("document.querySelector('#form1').submit()")
+ sessionRule.waitForResult(saveHandled2)
+
+ // Reload for the last time.
+ val session3 = sessionRule.createOpenSession()
+
+ session3.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginSelect(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSelectOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ assertThat(
+ "There should be two options",
+ prompt.options.size,
+ equalTo(2))
+
+ var usernames = arrayOf(user1, user2)
+ var passwords = arrayOf(pass1, pass2)
+
+ for (i in 0..1) {
+ val login = prompt.options[i].value
+
+ assertThat("Login should not be null", login, notNullValue())
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(usernames[i]))
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(passwords[i]))
+ }
+
+ val login = prompt.options[0].value
+ val modOption = LoginSelectOption(LoginEntry.Builder()
+ .origin(login.origin)
+ .formActionOrigin(login.formActionOrigin)
+ .username(userMod)
+ .password(passMod)
+ .build())
+
+ Handler().postDelayed({
+ selectHandled.complete(null)
+ }, acceptDelay)
+
+ return GeckoResult.fromValue(prompt.confirm(modOption))
+ }
+ })
+
+ session3.loadTestPath(FORMS3_HTML_PATH)
+ session3.waitForPageStop()
+
+ // Focus on the username input field.
+ session3.evaluateJS("document.querySelector('#user1').focus()")
+ sessionRule.waitForResult(selectHandled)
+
+ assertThat(
+ "Filled username should match",
+ session3.evaluateJS("document.querySelector('#user1').value") as String,
+ equalTo(userMod))
+
+ assertThat(
+ "Filled password should match",
+ session3.evaluateJS("document.querySelector('#pass1').value") as String,
+ equalTo(passMod))
+
+ // Submit the selection.
+ session3.evaluateJS("document.querySelector('#form1').submit()")
+ session3.waitForPageStop()
+ }
+
+ @Test
+ fun loginSelectGeneratedPassword() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ // Enable login management since it's disabled in automation.
+ "signon.rememberSignons" to true,
+ "signon.autofillForms.http" to true,
+ "signon.generation.enabled" to true,
+ "signon.generation.available" to true,
+ "dom.disable_open_during_load" to false,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ // Test:
+ // 1. Load a login form page.
+ // 2. Input username.
+ // 3. Focus on the password input field.
+ // a. Ensure onLoginSelect is called with a generated password.
+ // b. Return the login entry with the generated password.
+ // 4. Submit the login form.
+ // a. Ensure onLoginSave is called with accordingly.
+
+ val runtime = sessionRule.runtime
+ val register = { delegate: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = delegate
+ }
+ val unregister = { _: LoginStorageDelegate ->
+ runtime.loginStorageDelegate = null
+ }
+
+ val user1 = "user1x"
+ var genPass = ""
+
+ val saveHandled1 = GeckoResult<Void>()
+ val selectHandled = GeckoResult<Void>()
+ var numSelects = 0
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ LoginStorageDelegate::class, register, unregister,
+ object : LoginStorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String)
+ : GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ return GeckoResult.fromValue(null)
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLoginSave(login: LoginEntry) {
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(genPass))
+
+ saveHandled1.complete(null)
+ }
+
+ @AssertCalled(false)
+ override fun onLoginUsed(login: LoginEntry, usedFields: Int) {}
+ })
+
+ mainSession.loadTestPath(FORMS4_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled
+ override fun onLoginSelect(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSelectOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ assertThat(
+ "There should be one option",
+ prompt.options.size,
+ equalTo(1))
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat(
+ "Hint should match",
+ option.hint,
+ equalTo(LoginSelectOption.Hint.GENERATED))
+
+ assertThat("Login should not be null", login, notNullValue())
+ assertThat(
+ "Password should not be empty",
+ login.password,
+ not(isEmptyOrNullString()))
+
+ genPass = login.password
+
+ if (numSelects == 0) {
+ Handler().postDelayed({
+ selectHandled.complete(null)
+ }, acceptDelay)
+ }
+ ++numSelects
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLoginSave(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<LoginSaveOption>)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = prompt.options[0]
+ val login = option.value
+
+ assertThat("Login should not be null", login, notNullValue())
+
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(user1))
+
+ // TODO: The flag is only set for login entry updates yet.
+ /*
+ assertThat(
+ "Hint should match",
+ option.hint,
+ equalTo(LoginSaveOption.Hint.GENERATED))
+ */
+
+ assertThat(
+ "Password should not be empty",
+ login.password,
+ not(isEmptyOrNullString()))
+
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(genPass))
+
+ return GeckoResult.fromValue(prompt.confirm(option))
+ }
+ })
+
+ // Assign username and focus on password.
+ mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'")
+ mainSession.evaluateJS("document.querySelector('#pass1').focus()")
+ sessionRule.waitForResult(selectHandled)
+
+ assertThat(
+ "Filled username should match",
+ mainSession.evaluateJS("document.querySelector('#user1').value") as String,
+ equalTo(user1))
+
+ val filledPass = mainSession.evaluateJS(
+ "document.querySelector('#pass1').value") as String
+
+ assertThat(
+ "Password should not be empty",
+ filledPass,
+ not(isEmptyOrNullString()))
+
+ assertThat(
+ "Filled password should match",
+ filledPass,
+ equalTo(genPass))
+
+ // Submit the selection.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+ mainSession.waitForPageStop()
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt
new file mode 100644
index 0000000000..22e6f27c85
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt
@@ -0,0 +1,746 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.graphics.Matrix
+import android.os.Bundle
+import android.os.LocaleList
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Pair
+import android.util.SparseArray
+import android.view.View
+import android.view.ViewStructure
+import android.view.autofill.AutofillId
+import android.view.autofill.AutofillValue
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.Autofill
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class AutofillDelegateTest : BaseSessionTest() {
+
+ @Test fun autofillCommit() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "signon.rememberSignons" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ mainSession.loadTestPath(FORMS_HTML_PATH)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ // For the root document and the iframe document, each has a form group and
+ // a group for inputs outside of forms, so the total count is 4.
+ @AssertCalled(count = 4)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be starting auto-fill",
+ notification,
+ equalTo(forEachCall(
+ Autofill.Notify.SESSION_STARTED,
+ Autofill.Notify.NODE_ADDED)))
+ }
+ })
+
+ // Assign node values.
+ mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'")
+ mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'")
+ mainSession.evaluateJS("document.querySelector('#email1').value = 'e@mail.com'")
+ mainSession.evaluateJS("document.querySelector('#number1').value = '1'")
+
+ // Submit the session.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 5)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ val info = sessionRule.currentCall
+
+ if (info.counter < 5) {
+ assertThat("Should be an update notification",
+ notification,
+ equalTo(Autofill.Notify.NODE_UPDATED))
+ } else {
+ assertThat("Should be a commit notification",
+ notification,
+ equalTo(Autofill.Notify.SESSION_COMMITTED))
+
+ assertThat("Values should match",
+ countAutofillNodes({ it.value == "user1x" }),
+ equalTo(1))
+ assertThat("Values should match",
+ countAutofillNodes({ it.value == "pass1x" }),
+ equalTo(1))
+ assertThat("Values should match",
+ countAutofillNodes({ it.value == "e@mail.com" }),
+ equalTo(1))
+ assertThat("Values should match",
+ countAutofillNodes({ it.value == "1" }),
+ equalTo(1))
+ }
+ }
+ })
+ }
+
+ @Test fun autofillCommitIdValue() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "signon.rememberSignons" to true,
+ "signon.userInputRequiredToCapture.enabled" to false))
+
+ mainSession.loadTestPath(FORMS_ID_VALUE_HTML_PATH)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 1)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be starting auto-fill",
+ notification,
+ equalTo(forEachCall(
+ Autofill.Notify.SESSION_STARTED,
+ Autofill.Notify.NODE_ADDED)))
+ }
+ })
+
+ // Assign node values.
+ mainSession.evaluateJS("document.querySelector('#value').value = 'pass1x'")
+
+ // Submit the session.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 2)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ val info = sessionRule.currentCall
+
+ if (info.counter < 2) {
+ assertThat("Should be an update notification",
+ notification,
+ equalTo(Autofill.Notify.NODE_UPDATED))
+ } else {
+ assertThat("Should be a commit notification",
+ notification,
+ equalTo(Autofill.Notify.SESSION_COMMITTED))
+
+ assertThat("Values should match",
+ countAutofillNodes({ it.value == "pass1x" }),
+ equalTo(1))
+ }
+ }
+ })
+ }
+
+ @Test fun autofill() {
+ // Test parts of the Oreo auto-fill API; there is another autofill test in
+ // SessionAccessibility for a11y auto-fill support.
+ mainSession.loadTestPath(FORMS_HTML_PATH)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ // For the root document and the iframe document, each has a form group and
+ // a group for inputs outside of forms, so the total count is 4.
+ @AssertCalled(count = 4)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ }
+ })
+
+ val autofills = mapOf(
+ "#user1" to "bar", "#user2" to "bar",
+ "#pass1" to "baz", "#pass2" to "baz", "#email1" to "a@b.c",
+ "#number1" to "24", "#tel1" to "42")
+
+ // Set up promises to monitor the values changing.
+ val promises = autofills.flatMap { entry ->
+ // Repeat each test with both the top document and the iframe document.
+ arrayOf("document", "document.querySelector('#iframe').contentDocument").map { doc ->
+ mainSession.evaluatePromiseJS("""new Promise(resolve =>
+ $doc.querySelector('${entry.key}').addEventListener(
+ 'input', event => {
+ let eventInterface =
+ event instanceof InputEvent ? "InputEvent" :
+ event instanceof UIEvent ? "UIEvent" :
+ event instanceof Event ? "Event" : "Unknown";
+ resolve([
+ '${entry.key}',
+ event.target.value,
+ '${entry.value}',
+ eventInterface
+ ]);
+ }, { once: true }))""")
+ }
+ }
+
+ val autofillValues = SparseArray<CharSequence>()
+
+ // Perform auto-fill and return number of auto-fills performed.
+ fun checkAutofillChild(child: Autofill.Node) {
+ // Seal the node info instance so we can perform actions on it.
+ if (child.children.count() > 0) {
+ for (c in child.children) {
+ checkAutofillChild(c!!)
+ }
+ }
+
+ if (child.id == View.NO_ID) {
+ return
+ }
+
+ assertThat("Should have HTML tag",
+ child.tag, not(isEmptyOrNullString()))
+ assertThat("Web domain should match",
+ child.domain, equalTo(GeckoSessionTestRule.TEST_ENDPOINT))
+
+ if (child.inputType == Autofill.InputType.TEXT) {
+ assertThat("Input should be enabled", child.enabled, equalTo(true))
+ assertThat("Input should be focusable",
+ child.focusable, equalTo(true))
+
+ assertThat("Should have HTML tag", child.tag, equalTo("input"))
+ assertThat("Should have ID attribute", child.attributes.get("id"), not(isEmptyOrNullString()))
+ }
+
+ autofillValues.append(child.id, when (child.inputType) {
+ Autofill.InputType.NUMBER -> "24"
+ Autofill.InputType.PHONE -> "42"
+ Autofill.InputType.TEXT -> when (child.hint) {
+ Autofill.Hint.PASSWORD -> "baz"
+ Autofill.Hint.EMAIL_ADDRESS -> "a@b.c"
+ else -> "bar"
+ }
+ else -> "bar"
+ })
+ }
+
+ val nodes = mainSession.autofillSession.root
+ checkAutofillChild(nodes)
+
+ mainSession.autofill(autofillValues)
+
+ // Wait on the promises and check for correct values.
+ for ((key, actual, expected, eventInterface) in promises.map { it.value.asJSList<String>() }) {
+ assertThat("Auto-filled value must match ($key)", actual, equalTo(expected))
+ assertThat("input event should be dispatched with InputEvent interface", eventInterface, equalTo("InputEvent"))
+ }
+ }
+
+ private fun countAutofillNodes(cond: (Autofill.Node) -> Boolean =
+ { it.inputType != Autofill.InputType.NONE },
+ root: Autofill.Node? = null): Int {
+ val node = if (root !== null) root else mainSession.autofillSession.root
+ return (if (cond(node)) 1 else 0) +
+ node.children.sumBy {
+ countAutofillNodes(cond, it) }
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test fun autofillNavigation() {
+ // Wait for the accessibility nodes to populate.
+ mainSession.loadTestPath(FORMS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 4)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be starting auto-fill",
+ notification,
+ equalTo(forEachCall(
+ Autofill.Notify.SESSION_STARTED,
+ Autofill.Notify.NODE_ADDED)))
+ assertThat("Node should be valid", node, notNullValue())
+ }
+ })
+
+ assertThat("Initial auto-fill count should match",
+ countAutofillNodes(), equalTo(14))
+
+ // Now wait for the nodes to clear.
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 1)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be canceling auto-fill",
+ notification,
+ equalTo(Autofill.Notify.SESSION_CANCELED))
+ assertThat("Node should be null", node, nullValue())
+ }
+ })
+ assertThat("Should not have auto-fill fields",
+ countAutofillNodes(), equalTo(0))
+
+ // Now wait for the nodes to reappear.
+ mainSession.waitForPageStop()
+ mainSession.goBack()
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 4)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be starting auto-fill",
+ notification,
+ equalTo(forEachCall(
+ Autofill.Notify.SESSION_STARTED,
+ Autofill.Notify.NODE_ADDED)))
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+ assertThat("Should have auto-fill fields again",
+ countAutofillNodes(), equalTo(14))
+ assertThat("Should not have focused field",
+ countAutofillNodes({ it.focused }), equalTo(0))
+
+ mainSession.evaluateJS("document.querySelector('#pass2').focus()")
+
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 1)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be entering auto-fill view",
+ notification,
+ equalTo(Autofill.Notify.NODE_FOCUSED))
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+ assertThat("Should have one focused field",
+ countAutofillNodes({ it.focused }), equalTo(1))
+ // The focused field, its siblings, its parent, and the root node should
+ // be visible.
+ // Hidden elements are ignored.
+ // TODO: Is this actually correct? Should the whole focused branch be
+ // visible or just the nodes as described above?
+ assertThat("Should have seven visible nodes",
+ countAutofillNodes({ node -> node.visible }),
+ equalTo(6))
+
+ mainSession.evaluateJS("document.querySelector('#pass2').blur()")
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 1)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be exiting auto-fill view",
+ notification,
+ equalTo(Autofill.Notify.NODE_BLURRED))
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+ assertThat("Should not have focused field",
+ countAutofillNodes({ it.focused }), equalTo(0))
+ }
+
+ @WithDisplay(height = 100, width = 100)
+ @Test fun autofillUserpass() {
+ mainSession.loadTestPath(FORMS2_HTML_PATH)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 3)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Autofill notification should match", notification,
+ equalTo(forEachCall(Autofill.Notify.SESSION_STARTED,
+ Autofill.Notify.NODE_FOCUSED,
+ Autofill.Notify.NODE_ADDED)))
+ }
+ })
+
+ // Perform auto-fill and return number of auto-fills performed.
+ fun checkAutofillChild(child: Autofill.Node): Int {
+ var sum = 0
+ // Seal the node info instance so we can perform actions on it.
+ for (c in child.children) {
+ sum += checkAutofillChild(c!!)
+ }
+
+ if (child.hint == Autofill.Hint.NONE) {
+ return sum
+ }
+
+ assertThat("ID should be valid", child.id, not(equalTo(View.NO_ID)))
+ assertThat("Should have HTML tag", child.tag, equalTo("input"))
+
+ return sum + 1
+ }
+
+ val root = mainSession.autofillSession.root
+
+ // form and iframe have each have 2 nodes with hints.
+ assertThat("autofill hint count",
+ checkAutofillChild(root), equalTo(4))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test fun autofillActiveChange() {
+ // We should blur the active autofill node if the session is set
+ // inactive. Likewise, we should focus a node once we return.
+ mainSession.loadTestPath(FORMS_HTML_PATH)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ // For the root document and the iframe document, each has a form group and
+ // a group for inputs outside of forms, so the total count is 4.
+ @AssertCalled(count = 4)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be starting auto-fill",
+ notification,
+ equalTo(forEachCall(
+ Autofill.Notify.SESSION_STARTED,
+ Autofill.Notify.NODE_ADDED)))
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#pass2').focus()")
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 1)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be entering auto-fill view",
+ notification,
+ equalTo(Autofill.Notify.NODE_FOCUSED))
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+ assertThat("Should have one focused field",
+ countAutofillNodes({ it.focused }), equalTo(1))
+
+ // Make sure we get NODE_BLURRED when inactive
+ mainSession.setActive(false)
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 1)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be exiting auto-fill view",
+ notification,
+ equalTo(Autofill.Notify.NODE_BLURRED))
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+
+ // Make sure we get NODE_FOCUSED when active once again
+ mainSession.setActive(true)
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 1)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ assertThat("Should be entering auto-fill view",
+ notification,
+ equalTo(Autofill.Notify.NODE_FOCUSED))
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+ assertThat("Should have one focused field",
+ countAutofillNodes({ it.focused }), equalTo(1))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test fun autofillAutocompleteAttribute() {
+ mainSession.loadTestPath(FORMS_AUTOCOMPLETE_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.AutofillDelegate {
+ @AssertCalled(count = 3)
+ override fun onAutofill(session: GeckoSession,
+ notification: Int,
+ node: Autofill.Node?) {
+ }
+ });
+
+ fun checkAutofillChild(child: Autofill.Node): Int {
+ var sum = 0
+ for (c in child.children) {
+ sum += checkAutofillChild(c!!)
+ }
+ if (child.hint == Autofill.Hint.NONE) {
+ return sum
+ }
+ assertThat("Should have HTML tag", child.tag, equalTo("input"))
+ return sum + 1
+ }
+
+ val root = mainSession.autofillSession.root
+ // Each page has 3 nodes for autofill.
+ assertThat("autofill hint count",
+ checkAutofillChild(root), equalTo(6))
+ }
+
+ class MockViewNode : ViewStructure() {
+ private var mClassName: String? = null
+ private var mEnabled = false
+ private var mVisibility = -1
+ private var mPackageName: String? = null
+ private var mTypeName: String? = null
+ private var mEntryName: String? = null
+ private var mAutofillType = -1
+ private var mAutofillHints: Array<String>? = null
+ private var mInputType = -1
+ private var mHtmlInfo: HtmlInfo? = null
+ private var mWebDomain: String? = null
+ private var mFocused = false
+ private var mFocusable = false
+
+ var children = ArrayList<MockViewNode?>()
+ var id = View.NO_ID
+ var height = 0
+ var width = 0
+
+ val className get() = mClassName
+ val htmlInfo get() = mHtmlInfo
+ val autofillHints get() = mAutofillHints
+ val autofillType get() = mAutofillType
+ val webDomain get() = mWebDomain
+ val isEnabled get() = mEnabled
+ val isFocused get() = mFocused
+ val isFocusable get() = mFocusable
+ val visibility get() = mVisibility
+ val inputType get() = mInputType
+
+ override fun setId(id: Int, packageName: String?, typeName: String?, entryName: String?) {
+ this.id = id
+ mPackageName = packageName
+ mTypeName = typeName
+ mEntryName = entryName
+ }
+
+ override fun setHint(hint: CharSequence?) {
+ TODO("not implemented")
+ }
+
+ override fun setElevation(elevation: Float) {
+ TODO("not implemented")
+ }
+
+ override fun getText(): CharSequence {
+ TODO("not implemented")
+ }
+
+ override fun setText(text: CharSequence?) {
+ TODO("not implemented")
+ }
+
+ override fun setText(text: CharSequence?, selectionStart: Int, selectionEnd: Int) {
+ TODO("not implemented")
+ }
+
+ override fun asyncCommit() {
+ TODO("not implemented")
+ }
+
+ override fun getChildCount(): Int = children.size
+
+ override fun setEnabled(state: Boolean) {
+ mEnabled = state
+ }
+
+ override fun setLocaleList(localeList: LocaleList?) {
+ TODO("not implemented")
+ }
+
+ override fun setDimens(left: Int, top: Int, scrollX: Int, scrollY: Int, width: Int, height: Int) {
+ this.width = width
+ this.height = height
+ }
+
+ override fun setChecked(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun setContextClickable(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun setAccessibilityFocused(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun setAlpha(alpha: Float) {
+ TODO("not implemented")
+ }
+
+ override fun setTransformation(matrix: Matrix?) {
+ TODO("not implemented")
+ }
+
+ override fun setClassName(className: String?) {
+ mClassName = className
+ }
+
+ override fun setLongClickable(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun newChild(index: Int): ViewStructure {
+ val child = MockViewNode()
+ children[index] = child
+ return child
+ }
+
+ override fun getHint(): CharSequence {
+ TODO("not implemented")
+ }
+
+ override fun setInputType(inputType: Int) {
+ mInputType = inputType
+ }
+
+ override fun setWebDomain(domain: String?) {
+ mWebDomain = domain
+ }
+
+ override fun setAutofillOptions(options: Array<out CharSequence>?) {
+ TODO("not implemented")
+ }
+
+ override fun setTextStyle(size: Float, fgColor: Int, bgColor: Int, style: Int) {
+ TODO("not implemented")
+ }
+
+ override fun setVisibility(visibility: Int) {
+ mVisibility = visibility
+ }
+
+ override fun getAutofillId(): AutofillId? {
+ TODO("not implemented")
+ }
+
+ override fun setHtmlInfo(htmlInfo: HtmlInfo) {
+ mHtmlInfo = htmlInfo
+ }
+
+ override fun setTextLines(charOffsets: IntArray?, baselines: IntArray?) {
+ TODO("not implemented")
+ }
+
+ override fun getExtras(): Bundle {
+ TODO("not implemented")
+ }
+
+ override fun setClickable(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun newHtmlInfoBuilder(tagName: String): HtmlInfo.Builder {
+ return MockHtmlInfoBuilder(tagName)
+ }
+
+ override fun getTextSelectionEnd(): Int {
+ TODO("not implemented")
+ }
+
+ override fun setAutofillId(id: AutofillId) {
+ TODO("not implemented")
+ }
+
+ override fun setAutofillId(parentId: AutofillId, virtualId: Int) {
+ TODO("not implemented")
+ }
+
+ override fun hasExtras(): Boolean {
+ TODO("not implemented")
+ }
+
+ override fun addChildCount(num: Int): Int {
+ TODO("not implemented")
+ }
+
+ override fun setAutofillType(type: Int) {
+ mAutofillType = type
+ }
+
+ override fun setActivated(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun setFocused(state: Boolean) {
+ mFocused = state
+ }
+
+ override fun getTextSelectionStart(): Int {
+ TODO("not implemented")
+ }
+
+ override fun setChildCount(num: Int) {
+ children = ArrayList()
+ for (i in 0 until num) {
+ children.add(null)
+ }
+ }
+
+ override fun setAutofillValue(value: AutofillValue?) {
+ TODO("not implemented")
+ }
+
+ override fun setAutofillHints(hint: Array<String>?) {
+ mAutofillHints = hint
+ }
+
+ override fun setContentDescription(contentDescription: CharSequence?) {
+ TODO("not implemented")
+ }
+
+ override fun setFocusable(state: Boolean) {
+ mFocusable = state
+ }
+
+ override fun setCheckable(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun asyncNewChild(index: Int): ViewStructure {
+ TODO("not implemented")
+ }
+
+ override fun setSelected(state: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun setDataIsSensitive(sensitive: Boolean) {
+ TODO("not implemented")
+ }
+
+ override fun setOpaque(opaque: Boolean) {
+ TODO("not implemented")
+ }
+ }
+
+ class MockHtmlInfoBuilder(tagName: String) : ViewStructure.HtmlInfo.Builder() {
+ val mTagName = tagName
+ val mAttributes: MutableList<Pair<String, String>> = mutableListOf()
+
+ override fun addAttribute(name: String, value: String): ViewStructure.HtmlInfo.Builder {
+ mAttributes.add(Pair(name, value))
+ return this
+ }
+
+ override fun build(): ViewStructure.HtmlInfo {
+ return MockHtmlInfo(mTagName, mAttributes)
+ }
+ }
+
+ class MockHtmlInfo(tagName: String, attributes: MutableList<Pair<String, String>>)
+ : ViewStructure.HtmlInfo() {
+ private val mTagName = tagName
+ private val mAttributes = attributes
+
+ override fun getTag() = mTagName
+ override fun getAttributes() = mAttributes
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
new file mode 100644
index 0000000000..f8889282d3
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
@@ -0,0 +1,230 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test
+
+import android.os.Parcel
+import android.os.SystemClock
+import android.view.KeyEvent
+
+import androidx.test.platform.app.InstrumentationRegistry
+
+import org.mozilla.geckoview.GeckoRuntimeSettings
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Rule
+import org.junit.rules.ErrorCollector
+
+import kotlin.reflect.KClass
+
+/**
+ * Common base class for tests using GeckoSessionTestRule,
+ * providing the test rule and other utilities.
+ */
+open class BaseSessionTest(noErrorCollector: Boolean = false) {
+ companion object {
+ const val RESUBMIT_CONFIRM = "/assets/www/resubmit.html"
+ const val BEFORE_UNLOAD = "/assets/www/beforeunload.html"
+ const val CLICK_TO_RELOAD_HTML_PATH = "/assets/www/clickToReload.html"
+ const val CONTENT_CRASH_URL = "about:crashcontent"
+ const val DOWNLOAD_HTML_PATH = "/assets/www/download.html"
+ const val FORM_BLANK_HTML_PATH = "/assets/www/form_blank.html"
+ const val FORMS_HTML_PATH = "/assets/www/forms.html"
+ const val FORMS2_HTML_PATH = "/assets/www/forms2.html"
+ const val FORMS3_HTML_PATH = "/assets/www/forms3.html"
+ const val FORMS4_HTML_PATH = "/assets/www/forms4.html"
+ const val FORMS_AUTOCOMPLETE_HTML_PATH = "/assets/www/forms_autocomplete.html"
+ const val FORMS_ID_VALUE_HTML_PATH = "/assets/www/forms_id_value.html"
+ const val HELLO_HTML_PATH = "/assets/www/hello.html"
+ const val HELLO2_HTML_PATH = "/assets/www/hello2.html"
+ const val HELLO_IFRAME_HTML_PATH = "/assets/www/iframe_hello.html"
+ const val INPUTS_PATH = "/assets/www/inputs.html"
+ const val INVALID_URI = "not a valid uri"
+ const val LINKS_HTML_PATH = "/assets/www/links.html"
+ const val LOREM_IPSUM_HTML_PATH = "/assets/www/loremIpsum.html"
+ const val NEW_SESSION_CHILD_HTML_PATH = "/assets/www/newSession_child.html"
+ const val NEW_SESSION_HTML_PATH = "/assets/www/newSession.html"
+ const val POPUP_HTML_PATH = "/assets/www/popup.html"
+ const val PROMPT_HTML_PATH = "/assets/www/prompts.html"
+ const val SAVE_STATE_PATH = "/assets/www/saveState.html"
+ const val TITLE_CHANGE_HTML_PATH = "/assets/www/titleChange.html"
+ const val TRACKERS_PATH = "/assets/www/trackers.html"
+ const val VIDEO_OGG_PATH = "/assets/www/ogg.html"
+ const val VIDEO_MP4_PATH = "/assets/www/mp4.html"
+ const val VIDEO_WEBM_PATH = "/assets/www/webm.html"
+ const val VIDEO_BAD_PATH = "/assets/www/badVideoPath.html"
+ const val UNKNOWN_HOST_URI = "http://www.test.invalid/"
+ const val UNKNOWN_PROTOCOL_URI = "htt://invalid"
+ const val FULLSCREEN_PATH = "/assets/www/fullscreen.html"
+ const val VIEWPORT_PATH = "/assets/www/viewport.html"
+ const val IFRAME_REDIRECT_LOCAL = "/assets/www/iframe_redirect_local.html"
+ const val IFRAME_REDIRECT_AUTOMATION = "/assets/www/iframe_redirect_automation.html"
+ const val AUTOPLAY_PATH = "/assets/www/autoplay.html"
+ const val SCROLL_TEST_PATH = "/assets/www/scroll.html"
+ const val COLORS_HTML_PATH = "/assets/www/colors.html"
+ const val FIXED_BOTTOM = "/assets/www/fixedbottom.html"
+ const val FIXED_VH = "/assets/www/fixedvh.html"
+ const val FIXED_PERCENT = "/assets/www/fixedpercent.html"
+ const val STORAGE_TITLE_HTML_PATH = "/assets/www/reflect_local_storage_into_title.html"
+ const val HUNG_SCRIPT = "/assets/www/hungScript.html"
+ const val PUSH_HTML_PATH = "/assets/www/push/push.html"
+ const val OPEN_WINDOW_PATH = "/assets/www/worker/open_window.html"
+ const val OPEN_WINDOW_TARGET_PATH = "/assets/www/worker/open_window_target.html"
+ const val DATA_URI_PATH = "/assets/www/data_uri.html"
+ const val IFRAME_UNKNOWN_PROTOCOL = "/assets/www/iframe_unknown_protocol.html"
+ const val MEDIA_SESSION_DOM1_PATH = "/assets/www/media_session_dom1.html"
+ const val MEDIA_SESSION_DEFAULT1_PATH = "/assets/www/media_session_default1.html"
+ const val TOUCH_HTML_PATH = "/assets/www/touch.html"
+ const val GETUSERMEDIA_XORIGIN_CONTAINER_HTML_PATH = "/assets/www/getusermedia_xorigin_container.html"
+ const val ROOT_100_PERCENT_HEIGHT_HTML_PATH = "/assets/www/root_100_percent_height.html"
+ const val ROOT_98VH_HTML_PATH = "/assets/www/root_98vh.html"
+ const val ROOT_100VH_HTML_PATH = "/assets/www/root_100vh.html"
+ const val IFRAME_100_PERCENT_HEIGHT_NO_SCROLLABLE_HTML_PATH = "/assets/www/iframe_100_percent_height_no_scrollable.html"
+ const val IFRAME_100_PERCENT_HEIGHT_SCROLLABLE_HTML_PATH = "/assets/www/iframe_100_percent_height_scrollable.html"
+ const val IFRAME_98VH_SCROLLABLE_HTML_PATH = "/assets/www/iframe_98vh_scrollable.html"
+ const val IFRAME_98VH_NO_SCROLLABLE_HTML_PATH = "/assets/www/iframe_98vh_no_scrollable.html"
+ const val TOUCHSTART_HTML_PATH = "/assets/www/touchstart.html"
+
+ const val TEST_ENDPOINT = GeckoSessionTestRule.TEST_ENDPOINT
+ }
+
+ @get:Rule val sessionRule = GeckoSessionTestRule()
+
+ @get:Rule val errors = ErrorCollector()
+
+ val mainSession get() = sessionRule.session
+
+ fun <T> assertThat(reason: String, v: T, m: Matcher<in T>) = sessionRule.checkThat(reason, v, m)
+ fun <T> assertInAutomationThat(reason: String, v: T, m: Matcher<in T>) =
+ if (sessionRule.env.isAutomation) assertThat(reason, v, m)
+ else assumeThat(reason, v, m)
+
+ init {
+ if (!noErrorCollector) {
+ sessionRule.errorCollector = errors
+ }
+ }
+
+ fun <T> forEachCall(vararg values: T): T = sessionRule.forEachCall(*values)
+
+ fun getTestBytes(path: String) =
+ InstrumentationRegistry.getInstrumentation().targetContext.resources.assets
+ .open(path.removePrefix("/assets/")).readBytes()
+
+ fun createTestUrl(path: String) = GeckoSessionTestRule.TEST_ENDPOINT + path
+
+ fun GeckoSession.loadTestPath(path: String) =
+ this.loadUri(createTestUrl(path))
+
+ inline fun GeckoRuntimeSettings.toParcel(lambda: (Parcel) -> Unit) {
+ val parcel = Parcel.obtain()
+ try {
+ this.writeToParcel(parcel, 0)
+
+ val pos = parcel.dataPosition()
+ parcel.setDataPosition(0)
+
+ lambda(parcel)
+
+ assertThat("Read parcel matches written parcel",
+ parcel.dataPosition(), Matchers.equalTo(pos))
+ } finally {
+ parcel.recycle()
+ }
+ }
+
+ fun GeckoSession.open() =
+ sessionRule.openSession(this)
+
+ fun GeckoSession.waitForPageStop() =
+ sessionRule.waitForPageStop(this)
+
+ fun GeckoSession.waitForPageStops(count: Int) =
+ sessionRule.waitForPageStops(this, count)
+
+ fun GeckoSession.waitUntilCalled(ifce: KClass<*>, vararg methods: String) =
+ sessionRule.waitUntilCalled(this, ifce, *methods)
+
+ fun GeckoSession.waitUntilCalled(callback: Any) =
+ sessionRule.waitUntilCalled(this, callback)
+
+ fun GeckoSession.addDisplay(x: Int, y: Int) =
+ sessionRule.addDisplay(this, x, y)
+
+ fun GeckoSession.releaseDisplay() =
+ sessionRule.releaseDisplay(this)
+
+ fun GeckoSession.forCallbacksDuringWait(callback: Any) =
+ sessionRule.forCallbacksDuringWait(this, callback)
+
+ fun GeckoSession.delegateUntilTestEnd(callback: Any) =
+ sessionRule.delegateUntilTestEnd(this, callback)
+
+ fun GeckoSession.delegateDuringNextWait(callback: Any) =
+ sessionRule.delegateDuringNextWait(this, callback)
+
+ fun GeckoSession.synthesizeTap(x: Int, y: Int) =
+ sessionRule.synthesizeTap(this, x, y)
+
+ fun GeckoSession.evaluateJS(js: String): Any? =
+ sessionRule.evaluateJS(this, js)
+
+ fun GeckoSession.evaluatePromiseJS(js: String): GeckoSessionTestRule.ExtensionPromise =
+ sessionRule.evaluatePromiseJS(this, js)
+
+ fun GeckoSession.waitForJS(js: String): Any? =
+ sessionRule.waitForJS(this, js)
+
+ fun GeckoSession.waitForRoundTrip() = sessionRule.waitForRoundTrip(this)
+
+ fun GeckoSession.pressKey(keyCode: Int) {
+ // Create a Promise to listen to the key event, and wait on it below.
+ val promise = this.evaluatePromiseJS(
+ """new Promise(r => window.addEventListener(
+ 'keyup', r, { once: true }))""")
+ val time = SystemClock.uptimeMillis()
+ val keyEvent = KeyEvent(time, time, KeyEvent.ACTION_DOWN, keyCode, 0)
+ this.textInput.onKeyDown(keyCode, keyEvent)
+ this.textInput.onKeyUp(
+ keyCode, KeyEvent.changeAction(keyEvent, KeyEvent.ACTION_UP))
+ promise.value
+ }
+
+ fun GeckoSession.flushApzRepaints() = sessionRule.flushApzRepaints(this)
+
+ var GeckoSession.active: Boolean
+ get() = sessionRule.getActive(this)
+ set(value) = setActive(value)
+
+ @Suppress("UNCHECKED_CAST")
+ fun Any?.asJsonArray(): JSONArray = this as JSONArray
+
+ @Suppress("UNCHECKED_CAST")
+ fun<V> JSONObject.asMap(): Map<String?,V?> {
+ val result = HashMap<String?,V?>()
+ for (key in this.keys()) {
+ result[key] = this[key] as V
+ }
+ return result
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun<T> Any?.asJSList(): List<T> {
+ val array = this.asJsonArray()
+ val result = ArrayList<T>()
+
+ for (i in 0 until array.length()) {
+ result.add(array[i] as T)
+ }
+
+ return result
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt
new file mode 100644
index 0000000000..63bc028713
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt
@@ -0,0 +1,365 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.ContentBlocking
+import org.mozilla.geckoview.ContentBlockingController
+import org.mozilla.geckoview.ContentBlockingController.ContentBlockingException
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSessionSettings
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.util.Callbacks
+import org.junit.Assume.assumeThat
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentBlockingControllerTest : BaseSessionTest() {
+ private fun testTrackingProtectionException(baseSettings: GeckoSessionSettings) {
+ val category = ContentBlocking.AntiTracking.TEST
+ sessionRule.runtime.settings.contentBlocking.setAntiTracking(category)
+
+ val session1 = sessionRule.createOpenSession(baseSettings)
+ session1.loadTestPath(TRACKERS_PATH)
+
+ sessionRule.waitUntilCalled(
+ object : Callbacks.ContentBlockingDelegate {
+ @GeckoSessionTestRule.AssertCalled(count=3)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ assertThat("Category should be set",
+ event.antiTrackingCategory,
+ equalTo(category))
+ assertThat("URI should not be null", event.uri, notNullValue())
+ assertThat("URI should match", event.uri, endsWith("tracker.js"))
+ }
+ })
+
+ // Add exception for this site.
+ sessionRule.runtime.contentBlockingController.addException(session1)
+
+ sessionRule.runtime.contentBlockingController.checkException(session1).accept {
+ assertThat("Site should be on exceptions list", it, equalTo(true))
+ }
+
+ var list = sessionRule.waitForResult(sessionRule.runtime.contentBlockingController.saveExceptionList())
+ assertThat("Exceptions list should not be null", list, notNullValue())
+
+ if (baseSettings.usePrivateMode) {
+ assertThat(
+ "Exceptions list should be empty",
+ list.size,
+ equalTo(0))
+ } else {
+ assertThat(
+ "Exceptions list should have one entry",
+ list.size,
+ equalTo(1))
+ }
+
+ session1.reload()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.ContentBlockingDelegate {
+ @GeckoSessionTestRule.AssertCalled(false)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ }
+ })
+
+ // Remove exception for this site by passing GeckoSession.
+ sessionRule.runtime.contentBlockingController.removeException(session1)
+
+ list = sessionRule.waitForResult(
+ sessionRule.runtime.contentBlockingController.saveExceptionList())
+ assertThat("Exceptions list should not be null", list, notNullValue())
+ assertThat("Exceptions list should be empty", list.size, equalTo(0))
+
+ session1.reload()
+
+ sessionRule.waitUntilCalled(
+ object : Callbacks.ContentBlockingDelegate {
+ @GeckoSessionTestRule.AssertCalled(count=3)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ assertThat("Category should be set",
+ event.antiTrackingCategory,
+ equalTo(category))
+ assertThat("URI should not be null", event.uri, notNullValue())
+ assertThat("URI should match", event.uri, endsWith("tracker.js"))
+ }
+ })
+ }
+
+ @GeckoSessionTestRule.Setting(key = GeckoSessionTestRule.Setting.Key.USE_TRACKING_PROTECTION, value = "true")
+ @Test
+ fun trackingProtectionExceptionPrivateMode() {
+ // disable test on debug for frequently failing #Bug 1580223
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(false))
+
+ testTrackingProtectionException(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .usePrivateMode(true)
+ .build())
+ }
+
+ @GeckoSessionTestRule.Setting(key = GeckoSessionTestRule.Setting.Key.USE_TRACKING_PROTECTION, value = "true")
+ @Test
+ fun trackingProtectionException() {
+ // disable test on debug for frequently failing #Bug 1580223
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(false))
+
+ testTrackingProtectionException(mainSession.settings)
+ }
+
+ @Test
+ // Smoke test for safe browsing settings, most testing is through platform tests
+ fun safeBrowsingSettings() {
+ val contentBlocking = sessionRule.runtime.settings.contentBlocking
+
+ val google = contentBlocking.safeBrowsingProviders.first { it.name == "google" }
+ val google4 = contentBlocking.safeBrowsingProviders.first { it.name == "google4" }
+
+ // Let's make sure the initial value of safeBrowsingProviders is correct
+ assertThat("Expected number of default providers",
+ contentBlocking.safeBrowsingProviders.size,
+ equalTo(2))
+ assertThat("Google legacy provider is present", google, notNullValue())
+ assertThat("Google provider is present", google4, notNullValue())
+
+ // Checks that the default provider values make sense
+ assertThat("Default provider values are sensible",
+ google.getHashUrl, containsString("/safebrowsing-dummy/"))
+ assertThat("Default provider values are sensible",
+ google.advisoryUrl, startsWith("https://developers.google.com/"))
+ assertThat("Default provider values are sensible",
+ google4.getHashUrl, containsString("/safebrowsing4-dummy/"))
+ assertThat("Default provider values are sensible",
+ google4.updateUrl, containsString("/safebrowsing4-dummy/"))
+ assertThat("Default provider values are sensible",
+ google4.dataSharingUrl, startsWith("https://safebrowsing.googleapis.com/"))
+
+ // Checks that the pref value is also consistent with the runtime settings
+ val originalPrefs = sessionRule.getPrefs(
+ "browser.safebrowsing.provider.google4.updateURL",
+ "browser.safebrowsing.provider.google4.gethashURL",
+ "browser.safebrowsing.provider.google4.lists"
+ )
+
+ assertThat("Initial prefs value is correct",
+ originalPrefs[0] as String, equalTo(google4.updateUrl))
+ assertThat("Initial prefs value is correct",
+ originalPrefs[1] as String, equalTo(google4.getHashUrl))
+ assertThat("Initial prefs value is correct",
+ originalPrefs[2] as String, equalTo(google4.lists.joinToString(",")))
+
+ // Makes sure we can override a default value
+ val override = ContentBlocking.SafeBrowsingProvider
+ .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ .updateUrl("http://test-update-url.com")
+ .getHashUrl("http://test-get-hash-url.com")
+ .build()
+
+ // ... and that we can add a custom provider
+ val custom = ContentBlocking.SafeBrowsingProvider
+ .withName("custom-provider")
+ .updateUrl("http://test-custom-update-url.com")
+ .getHashUrl("http://test-custom-get-hash-url.com")
+ .lists("a", "b", "c")
+ .build()
+
+ assertThat("Override value is correct",
+ override.updateUrl, equalTo("http://test-update-url.com"))
+ assertThat("Override value is correct",
+ override.getHashUrl, equalTo("http://test-get-hash-url.com"))
+
+ assertThat("Custom provider value is correct",
+ custom.updateUrl, equalTo("http://test-custom-update-url.com"))
+ assertThat("Custom provider value is correct",
+ custom.getHashUrl, equalTo("http://test-custom-get-hash-url.com"))
+ assertThat("Custom provider value is correct",
+ custom.lists, equalTo(arrayOf("a", "b", "c")))
+
+ contentBlocking.setSafeBrowsingProviders(override, custom)
+
+ val prefs = sessionRule.getPrefs(
+ "browser.safebrowsing.provider.google4.updateURL",
+ "browser.safebrowsing.provider.google4.gethashURL",
+ "browser.safebrowsing.provider.custom-provider.updateURL",
+ "browser.safebrowsing.provider.custom-provider.gethashURL",
+ "browser.safebrowsing.provider.custom-provider.lists")
+
+ assertThat("Pref value is set correctly",
+ prefs[0] as String, equalTo("http://test-update-url.com"))
+ assertThat("Pref value is set correctly",
+ prefs[1] as String, equalTo("http://test-get-hash-url.com"))
+ assertThat("Pref value is set correctly",
+ prefs[2] as String, equalTo("http://test-custom-update-url.com"))
+ assertThat("Pref value is set correctly",
+ prefs[3] as String, equalTo("http://test-custom-get-hash-url.com"))
+ assertThat("Pref value is set correctly",
+ prefs[4] as String, equalTo("a,b,c"))
+
+ // Restore defaults
+ contentBlocking.setSafeBrowsingProviders(google, google4)
+
+ // Checks that after restoring the providers the prefs get updated
+ val restoredPrefs = sessionRule.getPrefs(
+ "browser.safebrowsing.provider.google4.updateURL",
+ "browser.safebrowsing.provider.google4.gethashURL",
+ "browser.safebrowsing.provider.google4.lists")
+
+ assertThat("Restored prefs value is correct",
+ restoredPrefs[0] as String, equalTo(originalPrefs[0]))
+ assertThat("Restored prefs value is correct",
+ restoredPrefs[1] as String, equalTo(originalPrefs[1]))
+ assertThat("Restored prefs value is correct",
+ restoredPrefs[2] as String, equalTo(originalPrefs[2]))
+ }
+
+ @GeckoSessionTestRule.Setting(key = GeckoSessionTestRule.Setting.Key.USE_TRACKING_PROTECTION, value = "true")
+ @Test
+ fun trackingProtectionExceptionRemoveByException() {
+ // disable test on debug for frequently failing #Bug 1580223
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(false))
+ val category = ContentBlocking.AntiTracking.TEST
+ sessionRule.runtime.settings.contentBlocking.setAntiTracking(category)
+ sessionRule.session.loadTestPath(TRACKERS_PATH)
+
+ sessionRule.waitUntilCalled(
+ object : Callbacks.ContentBlockingDelegate {
+ @GeckoSessionTestRule.AssertCalled(count=3)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ assertThat("Category should be set",
+ event.antiTrackingCategory,
+ equalTo(category))
+ assertThat("URI should not be null", event.uri, notNullValue())
+ assertThat("URI should match", event.uri, endsWith("tracker.js"))
+ }
+ })
+
+ // Add exception for this site.
+ sessionRule.runtime.contentBlockingController.addException(sessionRule.session)
+
+ sessionRule.runtime.contentBlockingController.checkException(sessionRule.session).accept {
+ assertThat("Site should be on exceptions list", it, equalTo(true))
+ }
+
+ var list = sessionRule.waitForResult(sessionRule.runtime.contentBlockingController.saveExceptionList())
+ assertThat("Exceptions list should not be null", list, notNullValue())
+ assertThat("Exceptions list should have one entry", list.size, equalTo(1))
+
+ sessionRule.session.reload()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.ContentBlockingDelegate {
+ @GeckoSessionTestRule.AssertCalled(false)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ }
+ })
+
+ // Remove exception for this site by passing ContentBlockingException.
+ sessionRule.runtime.contentBlockingController.removeException(list.get(0))
+
+ list = sessionRule.waitForResult(sessionRule.runtime.contentBlockingController.saveExceptionList())
+ assertThat("Exceptions list should not be null", list, notNullValue())
+ assertThat("Exceptions list should have one entry", list.size, equalTo(0))
+
+ sessionRule.session.reload()
+
+ sessionRule.waitUntilCalled(
+ object : Callbacks.ContentBlockingDelegate {
+ @GeckoSessionTestRule.AssertCalled(count=3)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ assertThat("Category should be set",
+ event.antiTrackingCategory,
+ equalTo(category))
+ assertThat("URI should not be null", event.uri, notNullValue())
+ assertThat("URI should match", event.uri, endsWith("tracker.js"))
+ }
+ })
+ }
+
+ @Test
+ fun importExportExceptions() {
+ // May provide useful info for 1580375.
+ sessionRule.setPrefsUntilTestEnd(mapOf("browser.safebrowsing.debug" to true))
+
+ val category = ContentBlocking.AntiTracking.TEST
+ sessionRule.runtime.settings.contentBlocking.setAntiTracking(category)
+ sessionRule.session.loadTestPath(TRACKERS_PATH)
+
+ sessionRule.waitForPageStop()
+
+ sessionRule.runtime.contentBlockingController.addException(sessionRule.session)
+
+ var export = sessionRule.waitForResult(sessionRule.runtime.contentBlockingController
+ .saveExceptionList())
+ assertThat("Exported list must not be null", export, notNullValue())
+ assertThat("Exported list must contain one entry", export.size, equalTo(1))
+
+ val exportJson = export.get(0).toJson()
+ assertThat("Exported JSON must not be null", exportJson, notNullValue())
+
+ // Wipe
+ sessionRule.runtime.contentBlockingController.clearExceptionList()
+ export = sessionRule.waitForResult(sessionRule.runtime.contentBlockingController
+ .saveExceptionList())
+ assertThat("Exported list must not be null", export, notNullValue())
+ assertThat("Exported list must contain zero entries", export.size, equalTo(0))
+
+ // Restore from JSON
+ val importJson = listOf(ContentBlockingException.fromJson(exportJson))
+ sessionRule.runtime.contentBlockingController.restoreExceptionList(importJson)
+
+ export = sessionRule.waitForResult(sessionRule.runtime.contentBlockingController
+ .saveExceptionList())
+ assertThat("Exported list must not be null", export, notNullValue())
+ assertThat("Exported list must contain one entry", export.size, equalTo(1))
+
+ // Wipe so as not to break other tests.
+ sessionRule.runtime.contentBlockingController.clearExceptionList()
+ }
+
+ @Test
+ fun getLog() {
+ val category = ContentBlocking.AntiTracking.TEST
+ sessionRule.runtime.settings.contentBlocking.setAntiTracking(category)
+ sessionRule.session.settings.useTrackingProtection = true
+ sessionRule.session.loadTestPath(TRACKERS_PATH)
+
+ sessionRule.waitUntilCalled(object : Callbacks.ContentBlockingDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+
+ }
+ })
+
+ sessionRule.waitForResult(sessionRule.runtime.contentBlockingController.getLog(sessionRule.session).accept {
+ assertThat("Log must not be null", it, notNullValue())
+ assertThat("Log must have at least one entry", it?.size, not(0))
+ it?.forEach {
+ it.blockingData.forEach {
+ assertThat("Category must match", it.category,
+ equalTo(ContentBlockingController.Event.BLOCKED_TRACKING_CONTENT))
+ assertThat("Blocked must be true", it.blocked, equalTo(true))
+ assertThat("Count must be at least 1", it.count, not(0))
+ }
+ }
+ })
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt
new file mode 100644
index 0000000000..12047e4d96
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt
@@ -0,0 +1,49 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Log
+import org.junit.After
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.BuildConfig
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.Setting
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentCrashTest : BaseSessionTest() {
+ val client = TestCrashHandler.Client(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ @Before
+ fun setup() {
+ assertTrue(client.connect(env.defaultTimeoutMillis))
+ client.setEvalNextCrashDump(/* expectFatal */ false)
+ }
+
+ @IgnoreCrash
+ @Test
+ fun crashContent() {
+ // We need the crash reporter for this test
+ assumeTrue(BuildConfig.MOZ_CRASHREPORTER)
+
+ mainSession.loadUri(CONTENT_CRASH_URL)
+ mainSession.waitUntilCalled(Callbacks.ContentDelegate::class, "onCrash")
+
+ // This test is really slow so we allow double the usual timeout
+ var evalResult = client.getEvalResult(env.defaultTimeoutMillis * 2)
+ assertTrue(evalResult.mMsg, evalResult.mResult)
+ }
+
+ @After
+ fun teardown() {
+ client.disconnect()
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt
new file mode 100644
index 0000000000..66dfde103a
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt
@@ -0,0 +1,185 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.app.ActivityManager
+import android.content.Context
+import android.graphics.Matrix
+import android.graphics.SurfaceTexture
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.LocaleList
+import android.os.Process
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+import androidx.annotation.AnyThread
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Pair
+import android.util.SparseArray
+import android.view.Surface
+import android.view.View
+import android.view.ViewStructure
+import android.view.autofill.AutofillId
+import android.view.autofill.AutofillValue
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.gecko.GeckoAppShell
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentDelegateMultipleSessionsTest : BaseSessionTest() {
+ val contentProcNameRegex = ".*:tab\\d+$".toRegex()
+
+ @AnyThread
+ fun killAllContentProcesses() {
+ val context = GeckoAppShell.getApplicationContext()
+ val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ for (info in manager.runningAppProcesses) {
+ if (info.processName.matches(contentProcNameRegex)) {
+ Process.killProcess(info.pid)
+ }
+ }
+ }
+
+ fun resetContentProcesses() {
+ val isMainSessionAlreadyOpen = mainSession.isOpen()
+ killAllContentProcesses()
+
+ if (isMainSessionAlreadyOpen) {
+ mainSession.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onKill(session: GeckoSession) {
+ }
+ })
+ }
+
+ mainSession.open()
+ }
+
+ fun getE10sProcessCount(): Int {
+ val extensionProcessPref = "extensions.webextensions.remote"
+ val isExtensionProcessEnabled = (sessionRule.getPrefs(extensionProcessPref)[0] as Boolean)
+ val e10sProcessCountPref = "dom.ipc.processCount"
+ var numContentProcesses = (sessionRule.getPrefs(e10sProcessCountPref)[0] as Int)
+
+ if (isExtensionProcessEnabled && numContentProcesses > 1) {
+ // Extension process counts against the content process budget
+ --numContentProcesses
+ }
+
+ return numContentProcesses
+ }
+
+ // This function ensures that a second GeckoSession that shares the same
+ // content process as mainSession is returned to the test:
+ //
+ // First, we assume that we're starting with a known initial state with respect
+ // to sessions and content processes:
+ // * mainSession is the only session, it is open, and its content process is the only
+ // content process (but note that the content process assigned to mainSession is
+ // *not* guaranteed to be ":tab0").
+ // * With multi-e10s configured to run N content processes, we create and open
+ // an additional N content processes. With the default e10s process allocation
+ // scheme, this means that the first N-1 new sessions we create each get their
+ // own content process. The Nth new session is assigned to the same content
+ // process as mainSession, which is the session we want to return to the test.
+ fun getSecondGeckoSession(): GeckoSession {
+ val numContentProcesses = getE10sProcessCount()
+
+ // If we change the content process allocation scheme, this function will need to be
+ // fixed to ensure that we still have two test sessions in at least one content
+ // process (with one of those sessions being mainSession).
+ val additionalSessions = Array(numContentProcesses) { _ -> sessionRule.createOpenSession() }
+
+ // The second session that shares a process with mainSession should be at
+ // the end of the array.
+ return additionalSessions.last()
+ }
+
+ @Before
+ fun setup() {
+ resetContentProcesses()
+ }
+
+ @IgnoreCrash
+ @Test fun crashContentMultipleSessions() {
+ // TODO: Bug 1673952
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ val newSession = getSecondGeckoSession()
+
+ // We can inadvertently catch the `onCrash` call for the cached session if we don't specify
+ // individual sessions here. Therefore, assert 'onCrash' is called for the two sessions
+ // individually...
+ val mainSessionCrash = GeckoResult<Void>()
+ val newSessionCrash = GeckoResult<Void>()
+
+ // ...but we use GeckoResult.allOf for waiting on the aggregated results
+ val allCrashesFound = GeckoResult.allOf(mainSessionCrash, newSessionCrash)
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ContentDelegate {
+ fun reportCrash(session: GeckoSession) {
+ if (session == mainSession) {
+ mainSessionCrash.complete(null)
+ } else if (session == newSession) {
+ newSessionCrash.complete(null)
+ }
+ }
+ // Slower devices may not catch crashes in a timely manner, so we check to see
+ // if either `onKill` or `onCrash` is called
+ override fun onCrash(session: GeckoSession) {
+ reportCrash(session)
+ }
+ override fun onKill(session: GeckoSession) {
+ reportCrash(session)
+ }
+ })
+
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+
+ mainSession.loadUri(CONTENT_CRASH_URL)
+
+ sessionRule.waitForResult(allCrashesFound)
+ }
+
+ @IgnoreCrash
+ @Test fun killContentMultipleSessions() {
+ val newSession = getSecondGeckoSession()
+
+ val mainSessionKilled = GeckoResult<Void>()
+ val newSessionKilled = GeckoResult<Void>()
+
+ val allKillEventsReceived = GeckoResult.allOf(mainSessionKilled, newSessionKilled)
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ContentDelegate {
+ override fun onKill(session: GeckoSession) {
+ if (session == mainSession) {
+ mainSessionKilled.complete(null)
+ } else if (session == newSession) {
+ newSessionKilled.complete(null)
+ }
+ }
+ })
+
+ killAllContentProcesses()
+
+ sessionRule.waitForResult(allKillEventsReceived)
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
new file mode 100644
index 0000000000..e776ca7556
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
@@ -0,0 +1,490 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.app.ActivityManager
+import android.content.Context
+import android.graphics.SurfaceTexture
+import android.net.Uri
+import android.os.Process
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+import androidx.annotation.AnyThread
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.view.Surface
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.gecko.GeckoAppShell
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentDelegateTest : BaseSessionTest() {
+ @Test fun titleChange() {
+ sessionRule.session.loadTestPath(TITLE_CHANGE_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 2)
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should match", title,
+ equalTo(forEachCall("Title1", "Title2")))
+ }
+ })
+ }
+
+ @Test fun downloadOneRequest() {
+ // disable test on pgo for frequently failing Bug 1543355
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(true))
+
+ sessionRule.session.loadTestPath(DOWNLOAD_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : Callbacks.NavigationDelegate, Callbacks.ContentDelegate {
+
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ return null
+ }
+
+ @AssertCalled(false)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
+ assertThat("Uri should start with data:", response.uri, startsWith("blob:"))
+ assertThat("We should download the thing", String(response.body?.readBytes()!!), equalTo("Downloaded Data"))
+ // The headers below are special headers that we try to get for responses of any kind (http, blob, etc.)
+ // Note the case of the header keys. In the WebResponse object, all of them are lower case.
+ assertThat("Content type should match", response.headers.get("content-type"), equalTo("text/plain"))
+ assertThat("Content length should be non-zero", response.headers.get("Content-Length")!!.toLong(), greaterThan(0L))
+ assertThat("Filename should match", response.headers.get("cONTent-diSPOsiTion"), equalTo("attachment; filename=\"download.txt\""))
+ }
+ })
+ }
+
+ @IgnoreCrash
+ @Test fun crashContent() {
+ mainSession.loadUri(CONTENT_CRASH_URL)
+ mainSession.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onCrash(session: GeckoSession) {
+ assertThat("Session should be closed after a crash",
+ session.isOpen, equalTo(false))
+ }
+ })
+
+ // Recover immediately
+ mainSession.open()
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitUntilCalled(object: Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ @IgnoreCrash
+ @WithDisplay(width = 10, height = 10)
+ @Test fun crashContent_tapAfterCrash() {
+ mainSession.delegateUntilTestEnd(object : Callbacks.ContentDelegate {
+ override fun onCrash(session: GeckoSession) {
+ mainSession.open()
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ }
+ })
+
+ mainSession.synthesizeTap(5, 5)
+ mainSession.loadUri(CONTENT_CRASH_URL)
+ mainSession.waitForPageStop()
+
+ mainSession.synthesizeTap(5, 5)
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @AnyThread
+ fun killAllContentProcesses() {
+ val context = GeckoAppShell.getApplicationContext()
+ val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ val expr = ".*:tab\\d+$".toRegex()
+ for (info in manager.runningAppProcesses) {
+ if (info.processName.matches(expr)) {
+ Process.killProcess(info.pid)
+ }
+ }
+ }
+
+ @IgnoreCrash
+ @Test fun killContent() {
+ killAllContentProcesses()
+ mainSession.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onKill(session: GeckoSession) {
+ assertThat("Session should be closed after being killed",
+ session.isOpen, equalTo(false))
+ }
+ })
+
+ mainSession.open()
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ private fun goFullscreen() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("full-screen-api.allow-trusted-requests-only" to false))
+ mainSession.loadTestPath(FULLSCREEN_PATH)
+ mainSession.waitForPageStop()
+ val promise = mainSession.evaluatePromiseJS("document.querySelector('#fullscreen').requestFullscreen()")
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
+ assertThat("Div went fullscreen", fullScreen, equalTo(true))
+ }
+ })
+ promise.value
+ }
+
+ private fun waitForFullscreenExit() {
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
+ assertThat("Div left fullscreen", fullScreen, equalTo(false))
+ }
+ })
+ }
+
+ @Test fun fullscreen() {
+ goFullscreen()
+ val promise = mainSession.evaluatePromiseJS("document.exitFullscreen()")
+ waitForFullscreenExit()
+ promise.value
+ }
+
+ @Test fun sessionExitFullscreen() {
+ goFullscreen()
+ mainSession.exitFullScreen()
+ waitForFullscreenExit()
+ }
+
+ @Test fun firstComposite() {
+ val display = mainSession.acquireDisplay()
+ val texture = SurfaceTexture(0)
+ texture.setDefaultBufferSize(100, 100)
+ val surface = Surface(texture)
+ display.surfaceChanged(surface, 100, 100)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstComposite(session: GeckoSession) {
+ }
+ })
+ display.surfaceDestroyed()
+ display.surfaceChanged(surface, 100, 100)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstComposite(session: GeckoSession) {
+ }
+ })
+ display.surfaceDestroyed()
+ mainSession.releaseDisplay(display)
+ }
+
+ @WithDisplay(width = 10, height = 10)
+ @Test fun firstContentfulPaint() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+ }
+
+ @Test fun webAppManifestPref() {
+ val initialState = sessionRule.runtime.settings.getWebManifestEnabled()
+ val jsToRun = "document.querySelector('link[rel=manifest]').relList.supports('manifest');"
+
+ // Check pref'ed off
+ sessionRule.runtime.settings.setWebManifestEnabled(false)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop(mainSession)
+
+ var result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean)
+
+ assertThat("Disabling pref makes relList.supports('manifest') return false", false, result)
+
+ // Check pref'ed on
+ sessionRule.runtime.settings.setWebManifestEnabled(true)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop(mainSession)
+
+ result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean)
+ assertThat("Enabling pref makes relList.supports('manifest') return true", true, result)
+
+ sessionRule.runtime.settings.setWebManifestEnabled(initialState)
+ }
+
+ @Test fun webAppManifest() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page load should succeed", success, equalTo(true))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onWebAppManifest(session: GeckoSession, manifest: JSONObject) {
+ // These values come from the manifest at assets/www/manifest.webmanifest
+ assertThat("name should match", manifest.getString("name"), equalTo("App"))
+ assertThat("short_name should match", manifest.getString("short_name"), equalTo("app"))
+ assertThat("display should match", manifest.getString("display"), equalTo("standalone"))
+
+ // The color here is "cadetblue" converted to #aarrggbb.
+ assertThat("theme_color should match", manifest.getString("theme_color"), equalTo("#ff5f9ea0"))
+ assertThat("background_color should match", manifest.getString("background_color"), equalTo("#eec0ffee"))
+ assertThat("start_url should match", manifest.getString("start_url"), endsWith("/assets/www/start/index.html"))
+
+ val icon = manifest.getJSONArray("icons").getJSONObject(0);
+
+ val iconSrc = Uri.parse(icon.getString("src"))
+ assertThat("icon should have a valid src", iconSrc, notNullValue())
+ assertThat("icon src should be absolute", iconSrc.isAbsolute, equalTo(true))
+ assertThat("icon should have sizes", icon.getString("sizes"), not(isEmptyOrNullString()))
+ assertThat("icon type should match", icon.getString("type"), equalTo("image/gif"))
+ }
+ })
+ }
+
+ @Test fun viewportFit() {
+ mainSession.loadTestPath(VIEWPORT_PATH)
+ mainSession.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page load should succeed", success, equalTo(true))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) {
+ assertThat("viewport-fit should match", viewportFit, equalTo("cover"))
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page load should succeed", success, equalTo(true))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) {
+ assertThat("viewport-fit should match", viewportFit, equalTo("auto"))
+ }
+ })
+ }
+
+ @Test fun closeRequest() {
+ if (!sessionRule.env.isAutomation) {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.allow_scripts_to_close_windows" to true))
+ }
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("window.close()")
+ mainSession.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onCloseRequest(session: GeckoSession) {
+ }
+ })
+ }
+
+ @Test fun windowOpenClose() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val newSession = sessionRule.createClosedSession()
+ mainSession.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return GeckoResult.fromValue(newSession)
+ }
+ })
+
+ mainSession.evaluateJS("const w = window.open('about:blank'); w.close()")
+
+ newSession.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled(count = 1)
+ override fun onCloseRequest(session: GeckoSession) {
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ /**
+ * Preferences to induce wanted behaviour.
+ */
+ private fun setHangReportTestPrefs(timeout: Int = 20000) {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "dom.max_script_run_time" to 1,
+ "dom.max_script_run_time_without_important_user_input" to 1,
+ "dom.max_chrome_script_run_time" to 1,
+ "dom.max_ext_content_script_run_time" to 1,
+ "dom.ipc.cpow.timeout" to 100,
+ "browser.hangNotification.waitPeriod" to timeout
+ ))
+ }
+
+ /**
+ * With no delegate set, the default behaviour is to stop hung scripts.
+ */
+ @NullDelegate(GeckoSession.ContentDelegate::class)
+ @Test fun stopHungProcessDefault() {
+ setHangReportTestPrefs()
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("The script did not complete.",
+ sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+ equalTo("Started"))
+ }
+ })
+ sessionRule.waitForPageStop(mainSession)
+ }
+
+ /**
+ * With no overriding implementation for onSlowScript, the default behaviour is to stop hung
+ * scripts.
+ */
+ @Test fun stopHungProcessNull() {
+ setHangReportTestPrefs()
+ sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+ // default onSlowScript returns null
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("The script did not complete.",
+ sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+ equalTo("Started"))
+ }
+ })
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.waitForPageStop(mainSession)
+ }
+
+ /**
+ * Test that, with a 'do nothing' delegate, the hung process completes after its delay
+ */
+ @Test fun stopHungProcessDoNothing() {
+ setHangReportTestPrefs()
+ var scriptHungReportCount = 0
+ sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+ @AssertCalled()
+ override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+ scriptHungReportCount += 1;
+ return GeckoResult.fromValue(null)
+ }
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("The delegate was informed of the hang repeatedly", scriptHungReportCount, greaterThan(1))
+ assertThat("The script did complete.",
+ sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+ equalTo("Finished"))
+ }
+ })
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.waitForPageStop(mainSession)
+ }
+
+ /**
+ * Test that the delegate is called and can stop a hung script
+ */
+ @Test fun stopHungProcess() {
+ setHangReportTestPrefs()
+ sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+ return GeckoResult.fromValue(SlowScriptResponse.STOP)
+ }
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("The script did not complete.",
+ sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+ equalTo("Started"))
+ }
+ })
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.waitForPageStop(mainSession)
+ }
+
+ /**
+ * Test that the delegate is called and can continue executing hung scripts
+ */
+ @Test fun stopHungProcessWait() {
+ setHangReportTestPrefs()
+ sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+ return GeckoResult.fromValue(SlowScriptResponse.CONTINUE)
+ }
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("The script did complete.",
+ sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+ equalTo("Finished"))
+ }
+ })
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.waitForPageStop(mainSession)
+ }
+
+ /**
+ * Test that the delegate is called and paused scripts re-notify after the wait period
+ */
+ @Test fun stopHungProcessWaitThenStop() {
+ setHangReportTestPrefs(500)
+ var scriptWaited = false
+ sessionRule.delegateUntilTestEnd(object : GeckoSession.ContentDelegate, Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2, order = [1, 2])
+ override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult<SlowScriptResponse> {
+ return if (!scriptWaited) {
+ scriptWaited = true;
+ GeckoResult.fromValue(SlowScriptResponse.CONTINUE)
+ } else {
+ GeckoResult.fromValue(SlowScriptResponse.STOP)
+ }
+ }
+ @AssertCalled(count = 1, order = [3])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("The script did not complete.",
+ sessionRule.session.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+ equalTo("Started"))
+ }
+ })
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.waitForPageStop(mainSession)
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt
new file mode 100644
index 0000000000..5fa21ca46f
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt
@@ -0,0 +1,23 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class DisplayTest : BaseSessionTest() {
+
+ @Test(expected = IllegalStateException::class)
+ fun doubleAcquire() {
+ val display = mainSession.acquireDisplay()
+ assertThat("Display should not be null", display, notNullValue())
+ try {
+ mainSession.acquireDisplay()
+ } finally {
+ mainSession.releaseDisplay(display)
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt
new file mode 100644
index 0000000000..c4be7e7a06
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt
@@ -0,0 +1,314 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.graphics.*
+import android.graphics.Bitmap
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Base64
+import java.io.ByteArrayOutputStream
+import org.hamcrest.Matchers.*
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.hamcrest.Matchers.closeTo
+import org.hamcrest.Matchers.equalTo
+
+private const val SCREEN_WIDTH = 100
+private const val SCREEN_HEIGHT = 200
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class DynamicToolbarTest : BaseSessionTest() {
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ // Makes sure we can load a page when the dynamic toolbar is bigger than the whole content
+ fun outOfRangeValue() {
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT + 1
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ }
+
+
+ private fun assertScreenshotResult(result: GeckoResult<Bitmap>, comparisonImage: Bitmap) {
+ sessionRule.waitForResult(result).let {
+ assertThat("Screenshot is not null",
+ it, notNullValue())
+ assertThat("Widths are the same", comparisonImage.width, equalTo(it.width))
+ assertThat("Heights are the same", comparisonImage.height, equalTo(it.height))
+ assertThat("Byte counts are the same", comparisonImage.byteCount, equalTo(it.byteCount))
+ assertThat("Configs are the same", comparisonImage.config, equalTo(it.config))
+
+ if (!comparisonImage.sameAs(it)) {
+ val outputForComparison = ByteArrayOutputStream()
+ comparisonImage.compress(Bitmap.CompressFormat.PNG, 100, outputForComparison)
+
+ val outputForActual = ByteArrayOutputStream()
+ it.compress(Bitmap.CompressFormat.PNG, 100, outputForActual)
+ val actualString: String = Base64.encodeToString(outputForActual.toByteArray(), Base64.DEFAULT)
+ val comparisonString: String = Base64.encodeToString(outputForComparison.toByteArray(), Base64.DEFAULT)
+
+ assertThat("Encoded strings are the same", comparisonString, equalTo(actualString))
+ }
+
+ assertThat("Bytes are the same", comparisonImage.sameAs(it), equalTo(true))
+ }
+ }
+
+ /**
+ * Returns a whole green Bitmap.
+ * This Bitmap would be a reference image of tests in this file.
+ */
+ private fun getComparisonScreenshot(width: Int, height: Int): Bitmap {
+ val screenshotFile = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(screenshotFile)
+ val paint = Paint()
+ paint.color = Color.rgb(0, 128, 0)
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
+ return screenshotFile
+ }
+
+ // With the dynamic toolbar max height vh units values exceed
+ // the top most window height. This is a test case that exceeded area
+ // is rendered properly (on the compositor).
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun positionFixedElementClipping() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(SCREEN_HEIGHT / 2) }
+
+ val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ // FIXED_VH is an HTML file which has a position:fixed element whose
+ // style is "width: 100%; height: 200vh" and the document is scaled by
+ // minimum-scale 0.5, so that the height of the element exceeds the
+ // window height.
+ mainSession.loadTestPath(BaseSessionTest.FIXED_VH)
+ mainSession.waitForPageStop()
+
+ // Scroll down bit, if we correctly render the document, the position
+ // fixed element still covers whole the document area.
+ mainSession.evaluateJS("window.scrollTo({ top: 100, behavior: 'instant' })")
+
+ // Wait a while to make sure the scrolling result is composited on the compositor
+ // since capturePixels() takes a snapshot directly from the compositor without
+ // waiting for a corresponding MozAfterPaint on the main-thread so it's possible
+ // to take a stale snapshot even if it's a result of syncronous scrolling.
+ mainSession.evaluateJS("new Promise(resolve => window.setTimeout(resolve, 1000))")
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), reference)
+ }
+ }
+
+ // Asynchronous scrolling with the dynamic toolbar max height causes
+ // situations where the visual viewport size gets bigger than the layout
+ // viewport on the compositor thread because of 200vh position:fixed
+ // elements. This is a test case that a 200vh position element is
+ // properly rendered its positions.
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun layoutViewportExpansion() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(SCREEN_HEIGHT / 2) }
+
+ val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ mainSession.loadTestPath(BaseSessionTest.FIXED_VH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("window.scrollTo(0, 100)")
+
+ // Scroll back to the original position by asynchronous scrolling.
+ mainSession.evaluateJS("window.scrollTo({ top: 0, behavior: 'smooth' })")
+
+ mainSession.evaluateJS("new Promise(resolve => window.setTimeout(resolve, 1000))")
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), reference)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun visualViewportEvents() {
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ mainSession.loadTestPath(BaseSessionTest.FIXED_VH)
+ mainSession.waitForPageStop()
+
+ val pixelRatio = sessionRule.session.evaluateJS("window.devicePixelRatio") as Double
+ val scale = sessionRule.session.evaluateJS("window.visualViewport.scale") as Double
+
+ for (i in 1..dynamicToolbarMaxHeight) {
+ // Simulate the dynamic toolbar is going to be hidden.
+ sessionRule.display?.run { setVerticalClipping(-i) }
+
+ val expectedViewportHeight = (SCREEN_HEIGHT - dynamicToolbarMaxHeight + i) / scale / pixelRatio
+ val promise = sessionRule.session.evaluatePromiseJS("""
+ new Promise(resolve => {
+ window.visualViewport.addEventListener('resize', resolve(window.visualViewport.height));
+ });
+ """.trimIndent())
+
+ assertThat("The visual viewport height should be changed in response to the dynamc toolbar transition",
+ promise.value as Double, closeTo(expectedViewportHeight, .01))
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun percentBaseValueOnPositionFixedElement() {
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ mainSession.loadTestPath(BaseSessionTest.FIXED_PERCENT)
+ mainSession.waitForPageStop()
+
+ val originalHeight = mainSession.evaluateJS("""
+ getComputedStyle(document.querySelector('#fixed-element')).height
+ """.trimIndent()) as String
+
+ // Set the vertical clipping value to the middle of toolbar transition.
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight / 2) }
+
+ var height = mainSession.evaluateJS("""
+ getComputedStyle(document.querySelector('#fixed-element')).height
+ """.trimIndent()) as String
+
+ assertThat("The %-based height should be the static in the middle of toolbar tansition",
+ height, equalTo(originalHeight))
+
+ // Set the vertical clipping value to hide the toolbar completely.
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+ height = mainSession.evaluateJS("""
+ getComputedStyle(document.querySelector('#fixed-element')).height
+ """.trimIndent()) as String
+
+ val scale = sessionRule.session.evaluateJS("window.visualViewport.scale") as Double
+ val expectedHeight = (SCREEN_HEIGHT / scale).toInt()
+ assertThat("The %-based height should be now recomputed based on the screen height",
+ height, equalTo(expectedHeight.toString() + "px"))
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun resizeEvents() {
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ mainSession.loadTestPath(BaseSessionTest.FIXED_VH)
+ mainSession.waitForPageStop()
+
+ for (i in 1..dynamicToolbarMaxHeight - 1) {
+ val promise = sessionRule.session.evaluatePromiseJS("""
+ new Promise(resolve => {
+ let fired = false;
+ window.addEventListener('resize', () => { fired = true; }, { once: true });
+ // Note that `resize` event is fired just before rAF callbacks, so under ideal
+ // circumstances waiting for a rAF should be sufficient, even if it's not sufficient
+ // unexpected resize event(s) will be caught in the next loop.
+ requestAnimationFrame(() => { resolve(fired); });
+ });
+ """.trimIndent())
+
+ // Simulate the dynamic toolbar is going to be hidden.
+ sessionRule.display?.run { setVerticalClipping(-i) }
+ assertThat("'resize' event on window should not be fired in response to the dynamc toolbar transition",
+ promise.value as Boolean, equalTo(false));
+ }
+
+ val promise = sessionRule.session.evaluatePromiseJS("""
+ new Promise(resolve => {
+ window.addEventListener('resize', () => { resolve(true); }, { once: true });
+ });
+ """.trimIndent())
+
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+ assertThat("'resize' event on window should be fired when the dynamc toolbar is completely hidden",
+ promise.value as Boolean, equalTo(true))
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun windowInnerHeight() {
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ // We intentionally use FIXED_BOTTOM instead of FIXED_VH in this test since
+ // FIXED_VH has `minimum-scale=0.5` thus we can't properly test window.innerHeight
+ // with FXIED_VH for now due to bug 1598487.
+ mainSession.loadTestPath(BaseSessionTest.FIXED_BOTTOM)
+ mainSession.waitForPageStop()
+
+ val pixelRatio = sessionRule.session.evaluateJS("window.devicePixelRatio") as Double
+
+ for (i in 1..dynamicToolbarMaxHeight - 1) {
+ val promise = sessionRule.session.evaluatePromiseJS("""
+ new Promise(resolve => {
+ window.visualViewport.addEventListener('resize', resolve(window.innerHeight));
+ });
+ """.trimIndent())
+
+ // Simulate the dynamic toolbar is going to be hidden.
+ sessionRule.display?.run { setVerticalClipping(-i) }
+ assertThat("window.innerHeight should not be changed in response to the dynamc toolbar transition",
+ promise.value as Double, closeTo(SCREEN_HEIGHT / 2 / pixelRatio, .01))
+ }
+
+ val promise = sessionRule.session.evaluatePromiseJS("""
+ new Promise(resolve => {
+ window.addEventListener('resize', () => { resolve(window.innerHeight); }, { once: true });
+ });
+ """.trimIndent())
+
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+ assertThat("window.innerHeight should be changed when the dynamc toolbar is completely hidden",
+ promise.value as Double, closeTo(SCREEN_HEIGHT / pixelRatio, .01))
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun notCrashOnResizeEvent() {
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ mainSession.loadTestPath(BaseSessionTest.FIXED_VH)
+ mainSession.waitForPageStop()
+
+ val promise = sessionRule.session.evaluatePromiseJS("""
+ new Promise(resolve => window.addEventListener('resize', () => resolve(true)));
+ """.trimIndent())
+
+ // Do some setVerticalClipping calls that we might try to queue two window resize events.
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight + 1) }
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+
+ assertThat("Got a rezie event", promise.value as Boolean, equalTo(true))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt
new file mode 100644
index 0000000000..bf1d8a4f6b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt
@@ -0,0 +1,643 @@
+package org.mozilla.geckoview.test
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.equalTo
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.WebExtension
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+
+@MediumTest
+@RunWith(Parameterized::class)
+class ExtensionActionTest : BaseSessionTest() {
+ private var extension: WebExtension? = null
+ private var default: WebExtension.Action? = null
+ private var backgroundPort: WebExtension.Port? = null
+ private var windowPort: WebExtension.Port? = null
+
+ companion object {
+ @get:Parameterized.Parameters(name = "{0}")
+ @JvmStatic
+ val parameters = listOf(
+ arrayOf("#pageAction"),
+ arrayOf("#browserAction"))
+ }
+
+ @field:Parameterized.Parameter(0) @JvmField var id: String = ""
+
+ private val controller
+ get() = sessionRule.runtime.webExtensionController
+
+ @Before
+ fun setup() {
+ controller.setTabActive(mainSession, true)
+
+ // This method installs the extension, opens up ports with the background script and the
+ // content script and captures the default action definition from the manifest
+ val browserActionDefaultResult = GeckoResult<WebExtension.Action>()
+ val pageActionDefaultResult = GeckoResult<WebExtension.Action>()
+
+ val windowPortResult = GeckoResult<WebExtension.Port>()
+ val backgroundPortResult = GeckoResult<WebExtension.Port>()
+
+ extension = sessionRule.waitForResult(
+ controller.installBuiltIn("resource://android/assets/web_extensions/actions/"));
+
+ sessionRule.session.webExtensionController.setMessageDelegate(
+ extension!!,
+ object : WebExtension.MessageDelegate {
+ override fun onConnect(port: WebExtension.Port) {
+ windowPortResult.complete(port)
+ }
+ }, "browser")
+ extension!!.setMessageDelegate(object : WebExtension.MessageDelegate {
+ override fun onConnect(port: WebExtension.Port) {
+ backgroundPortResult.complete(port)
+ }
+ }, "browser")
+
+ sessionRule.addExternalDelegateDuringNextWait(
+ WebExtension.ActionDelegate::class,
+ extension!!::setActionDelegate,
+ { extension!!.setActionDelegate(null) },
+ object : WebExtension.ActionDelegate {
+ override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+ assertEquals(action.title, "Test action default")
+ browserActionDefaultResult.complete(action)
+ }
+ override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+ assertEquals(action.title, "Test action default")
+ pageActionDefaultResult.complete(action)
+ }
+ })
+
+ sessionRule.session.loadUri("http://example.com")
+ sessionRule.waitForPageStop()
+
+ val pageAction = sessionRule.waitForResult(pageActionDefaultResult)
+ val browserAction = sessionRule.waitForResult(browserActionDefaultResult)
+
+ default = when (id) {
+ "#pageAction" -> pageAction
+ "#browserAction" -> browserAction
+ else -> throw IllegalArgumentException()
+ }
+
+ windowPort = sessionRule.waitForResult(windowPortResult)
+ backgroundPort = sessionRule.waitForResult(backgroundPortResult)
+
+ if (id == "#pageAction") {
+ // Make sure that the pageAction starts enabled for this tab
+ testActionApi("""{"action": "enable"}""") { action ->
+ assertEquals(action.enabled, true)
+ }
+ }
+ }
+
+ private val type: String
+ get() = when(id) {
+ "#pageAction" -> "pageAction"
+ "#browserAction" -> "browserAction"
+ else -> throw IllegalArgumentException()
+ }
+
+ @After
+ fun tearDown() {
+ if (extension != null) {
+ extension!!.setMessageDelegate(null, "browser")
+ extension!!.setActionDelegate(null)
+ sessionRule.waitForResult(controller.uninstall(extension!!))
+ }
+ }
+
+ private fun testBackgroundActionApi(message: String, tester: (WebExtension.Action) -> Unit) {
+ val result = GeckoResult<Void>()
+
+ val json = JSONObject(message)
+ json.put("type", type)
+
+ backgroundPort!!.postMessage(json)
+
+ sessionRule.addExternalDelegateDuringNextWait(
+ WebExtension.ActionDelegate::class,
+ extension!!::setActionDelegate,
+ { extension!!.setActionDelegate(null) },
+ object : WebExtension.ActionDelegate {
+ override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+ if (sessionRule.currentCall.counter == 1) {
+ // When attaching the delegate, we will receive a default message, ignore it
+ return
+ }
+ assertEquals(id, "#browserAction")
+ default = action
+ tester(action)
+ result.complete(null)
+ }
+ override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+ if (sessionRule.currentCall.counter == 1) {
+ // When attaching the delegate, we will receive a default message, ignore it
+ return
+ }
+ assertEquals(id, "#pageAction")
+ default = action
+ tester(action)
+ result.complete(null)
+ }
+ })
+
+ sessionRule.waitForResult(result)
+ }
+
+ private fun testActionApi(message: String, tester: (WebExtension.Action) -> Unit) {
+ val result = GeckoResult<Void>()
+
+ val json = JSONObject(message)
+ json.put("type", type)
+
+ windowPort!!.postMessage(json)
+
+ sessionRule.addExternalDelegateDuringNextWait(
+ WebExtension.ActionDelegate::class,
+ { delegate ->
+ sessionRule.session.webExtensionController.setActionDelegate(extension!!, delegate) },
+ { sessionRule.session.webExtensionController.setActionDelegate(extension!!, null) },
+ object : WebExtension.ActionDelegate {
+ override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+ assertEquals(id, "#browserAction")
+ val resolved = action.withDefault(default!!)
+ tester(resolved)
+ result.complete(null)
+ }
+ override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+ assertEquals(id, "#pageAction")
+ val resolved = action.withDefault(default!!)
+ tester(resolved)
+ result.complete(null)
+ }
+ })
+
+ sessionRule.waitForResult(result)
+ }
+
+ @Test
+ fun disableTest() {
+ testActionApi("""{"action": "disable"}""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, false)
+ }
+ }
+
+ @Test
+ fun attachingDelegateTriggersDefaultUpdate() {
+ val result = GeckoResult<Void>()
+
+ // We should always get a default update after we attach the delegate
+ when (id) {
+ "#browserAction" -> {
+ extension!!.setActionDelegate(object : WebExtension.ActionDelegate {
+ override fun onBrowserAction(extension: WebExtension, session: GeckoSession?,
+ action: WebExtension.Action) {
+ assertEquals(action.title, "Test action default")
+ result.complete(null)
+ }
+ })
+ }
+ "#pageAction" -> {
+ extension!!.setActionDelegate(object : WebExtension.ActionDelegate {
+ override fun onPageAction(extension: WebExtension, session: GeckoSession?,
+ action: WebExtension.Action) {
+ assertEquals(action.title, "Test action default")
+ result.complete(null)
+ }
+ })
+ }
+ else -> throw IllegalArgumentException()
+ }
+
+ sessionRule.waitForResult(result)
+ }
+
+ @Test
+ fun enableTest() {
+ // First, make sure the action is disabled
+ testActionApi("""{"action": "disable"}""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, false)
+ }
+
+ testActionApi("""{"action": "enable"}""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+ }
+ }
+
+ @Test
+ fun setOverridenTitle() {
+ testActionApi("""{
+ "action": "setTitle",
+ "title": "overridden title"
+ }""") { action ->
+ assertEquals(action.title, "overridden title")
+ assertEquals(action.enabled, true)
+ }
+ }
+
+ @Test
+ fun setBadgeText() {
+ assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction"))
+
+ testActionApi("""{
+ "action": "setBadgeText",
+ "text": "12"
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.badgeText, "12")
+ assertEquals(action.enabled, true)
+ }
+ }
+
+ @Test
+ fun setBadgeBackgroundColor() {
+ assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction"))
+
+ colorTest("setBadgeBackgroundColor", "#ABCDEF", "#FFABCDEF")
+ colorTest("setBadgeBackgroundColor", "#F0A", "#FFFF00AA")
+ colorTest("setBadgeBackgroundColor", "red", "#FFFF0000")
+ colorTest("setBadgeBackgroundColor", "rgb(0, 0, 255)", "#FF0000FF")
+ colorTest("setBadgeBackgroundColor", "rgba(0, 255, 0, 0.5)", "#8000FF00")
+ colorRawTest("setBadgeBackgroundColor", "[0, 0, 255, 128]", "#800000FF")
+ }
+
+ private fun colorTest(actionName: String, color: String, expectedHex: String) {
+ colorRawTest(actionName, "\"$color\"", expectedHex)
+ }
+
+ private fun colorRawTest(actionName: String, color: String, expectedHex: String) {
+ testActionApi("""{
+ "action": "$actionName",
+ "color": $color
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.badgeText, "")
+ assertEquals(action.enabled, true)
+
+ val result = when (actionName) {
+ "setBadgeTextColor" -> action.badgeTextColor!!
+ "setBadgeBackgroundColor" -> action.badgeBackgroundColor!!
+ else -> throw IllegalArgumentException()
+ }
+
+ val hexColor = String.format("#%08X", result)
+ assertEquals(hexColor, expectedHex)
+ }
+ }
+
+ @Test
+ fun setBadgeTextColor() {
+ assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction"))
+
+ colorTest("setBadgeTextColor", "#ABCDEF", "#FFABCDEF")
+ colorTest("setBadgeTextColor", "#F0A", "#FFFF00AA")
+ colorTest("setBadgeTextColor", "red", "#FFFF0000")
+ colorTest("setBadgeTextColor", "rgb(0, 0, 255)", "#FF0000FF")
+ colorTest("setBadgeTextColor", "rgba(0, 255, 0, 0.5)", "#8000FF00")
+ colorRawTest("setBadgeTextColor", "[0, 0, 255, 128]", "#800000FF")
+ }
+
+ @Test
+ fun setDefaultTitle() {
+ assumeThat("Only browserAction supports default properties.", id, equalTo("#browserAction"))
+
+ // Setting a default value will trigger the default handler on the extension object
+ testBackgroundActionApi("""{
+ "action": "setTitle",
+ "title": "new default title"
+ }""") { action ->
+ assertEquals(action.title, "new default title")
+ assertEquals(action.badgeText, "")
+ assertEquals(action.enabled, true)
+ }
+
+ // When an overridden title is set, the default has no effect
+ testActionApi("""{
+ "action": "setTitle",
+ "title": "test override"
+ }""") { action ->
+ assertEquals(action.title, "test override")
+ assertEquals(action.badgeText, "")
+ assertEquals(action.enabled, true)
+ }
+
+ // When the override is null, the new default takes effect
+ testActionApi("""{
+ "action": "setTitle",
+ "title": null
+ }""") { action ->
+ assertEquals(action.title, "new default title")
+ assertEquals(action.badgeText, "")
+ assertEquals(action.enabled, true)
+ }
+
+ // When the default value is null, the manifest value is used
+ testBackgroundActionApi("""{
+ "action": "setTitle",
+ "title": null
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.badgeText, "")
+ assertEquals(action.enabled, true)
+ }
+ }
+
+ private fun compareBitmap(expectedLocation: String, actual: Bitmap) {
+ val stream = InstrumentationRegistry.getInstrumentation().targetContext.assets
+ .open(expectedLocation)
+
+ val expected = BitmapFactory.decodeStream(stream)
+ for (x in 0 until actual.height) {
+ for (y in 0 until actual.width) {
+ assertEquals(expected.getPixel(x, y), actual.getPixel(x, y))
+ }
+ }
+ }
+
+ @Test
+ fun setIconSvg() {
+ val svg = GeckoResult<Void>()
+
+ testActionApi("""{
+ "action": "setIcon",
+ "path": "button/icon.svg"
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+
+ action.icon!!.getBitmap(100).accept { actual ->
+ compareBitmap("web_extensions/actions/button/expected.png", actual!!)
+ svg.complete(null)
+ }
+ }
+
+ sessionRule.waitForResult(svg)
+ }
+
+ @Test
+ fun themeIcons() {
+ assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction"))
+
+ val png32 = GeckoResult<Void>()
+
+ default!!.icon!!.getBitmap(32).accept ({ actual ->
+ compareBitmap("web_extensions/actions/button/beasts-32.png", actual!!)
+ png32.complete(null)
+ }, { error ->
+ png32.completeExceptionally(error!!)
+ })
+
+ sessionRule.waitForResult(png32)
+ }
+
+ @Test
+ fun setIconPng() {
+ val png100 = GeckoResult<Void>()
+ val png38 = GeckoResult<Void>()
+ val png19 = GeckoResult<Void>()
+ val png10 = GeckoResult<Void>()
+
+ testActionApi("""{
+ "action": "setIcon",
+ "path": {
+ "19": "button/geo-19.png",
+ "38": "button/geo-38.png"
+ }
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+
+ action.icon!!.getBitmap(100).accept { actual ->
+ compareBitmap("web_extensions/actions/button/geo-38.png", actual!!)
+ png100.complete(null)
+ }
+
+ action.icon!!.getBitmap(38).accept { actual ->
+ compareBitmap("web_extensions/actions/button/geo-38.png", actual!!)
+ png38.complete(null)
+ }
+
+ action.icon!!.getBitmap(19).accept { actual ->
+ compareBitmap("web_extensions/actions/button/geo-19.png", actual!!)
+ png19.complete(null)
+ }
+
+ action.icon!!.getBitmap(10).accept { actual ->
+ compareBitmap("web_extensions/actions/button/geo-19.png", actual!!)
+ png10.complete(null)
+ }
+ }
+
+ sessionRule.waitForResult(png100)
+ sessionRule.waitForResult(png38)
+ sessionRule.waitForResult(png19)
+ sessionRule.waitForResult(png10)
+ }
+
+ @Test
+ fun setIconError() {
+ val error = GeckoResult<Void>()
+
+ testActionApi("""{
+ "action": "setIcon",
+ "path": "invalid/path/image.png"
+ }""") { action ->
+ action.icon!!.getBitmap(38).accept({
+ error.completeExceptionally(RuntimeException("Should not succeed."))
+ }, { exception ->
+ assertTrue(exception is IllegalArgumentException)
+ error.complete(null)
+ })
+ }
+
+ sessionRule.waitForResult(error)
+ }
+
+ @Test
+ @GeckoSessionTestRule.WithDisplay(width=100, height=100)
+ @Ignore("This test fails intermittently on try")
+ fun testOpenPopup() {
+ // First, let's make sure we have a popup set
+ val actionResult = GeckoResult<Void>()
+ testActionApi("""{
+ "action": "setPopup",
+ "popup": "test-popup.html"
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+
+ actionResult.complete(null)
+ }
+
+ val url = when(id) {
+ "#browserAction" -> "/test-open-popup-browser-action.html"
+ "#pageAction" -> "/test-open-popup-page-action.html"
+ else -> throw IllegalArgumentException()
+ }
+
+ windowPort!!.postMessage(JSONObject("""{
+ "type": "load",
+ "url": "$url"
+ }"""))
+
+ val openPopup = GeckoResult<Void>()
+ sessionRule.session.webExtensionController.setActionDelegate(extension!!,
+ object : WebExtension.ActionDelegate {
+ override fun onOpenPopup(extension: WebExtension,
+ popupAction: WebExtension.Action): GeckoResult<GeckoSession>? {
+ assertEquals(extension, this@ExtensionActionTest.extension)
+ // assertEquals(popupAction, this@ExtensionActionTest.default)
+ openPopup.complete(null)
+ return null
+ }
+ })
+
+ sessionRule.waitForPageStops(2)
+ // openPopup needs user activation
+ sessionRule.session.synthesizeTap(50, 50)
+
+ sessionRule.waitForResult(openPopup)
+ }
+
+ @Test
+ fun testClickWhenPopupIsNotDefined() {
+ val pong = GeckoResult<Void>()
+
+ backgroundPort!!.setDelegate(object : WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any, port: WebExtension.Port) {
+ val json = message as JSONObject
+ if (json.getString("method") == "pong") {
+ pong.complete(null)
+ } else {
+ // We should NOT receive onClicked here
+ pong.completeExceptionally(IllegalArgumentException(
+ "Received unexpected: ${json.getString("method")}"))
+ }
+ }
+ })
+
+ val actionResult = GeckoResult<WebExtension.Action>()
+
+ testActionApi("""{
+ "action": "setPopup",
+ "popup": "test-popup.html"
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+
+ actionResult.complete(action)
+ }
+
+ val togglePopup = GeckoResult<Void>()
+ val action = sessionRule.waitForResult(actionResult)
+
+ extension!!.setActionDelegate(object : WebExtension.ActionDelegate {
+ override fun onTogglePopup(extension: WebExtension,
+ popupAction: WebExtension.Action): GeckoResult<GeckoSession>? {
+ assertEquals(extension, this@ExtensionActionTest.extension)
+ assertEquals(popupAction, action)
+ togglePopup.complete(null)
+ return null
+ }
+ })
+
+ // This click() will not cause an onClicked callback because popup is set
+ action.click()
+
+ // but it will cause togglePopup to be called
+ sessionRule.waitForResult(togglePopup)
+
+ // If the response to ping reaches us before the onClicked we know onClicked wasn't called
+ backgroundPort!!.postMessage(JSONObject("""{
+ "type": "ping"
+ }"""))
+
+ sessionRule.waitForResult(pong)
+ }
+
+ @Test
+ fun testClickWhenPopupIsDefined() {
+ val onClicked = GeckoResult<Void>()
+ backgroundPort!!.setDelegate(object : WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any, port: WebExtension.Port) {
+ val json = message as JSONObject
+ assertEquals(json.getString("method"), "onClicked")
+ assertEquals(json.getString("type"), type)
+ onClicked.complete(null)
+ }
+ })
+
+ testActionApi("""{
+ "action": "setPopup",
+ "popup": null
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+
+ // This click() WILL cause an onClicked callback
+ action.click()
+ }
+
+ sessionRule.waitForResult(onClicked)
+ }
+
+ @Test
+ fun testPopupsCanCloseThemselves() {
+ val onCloseRequestResult = GeckoResult<Void>()
+ val popupSession = sessionRule.createOpenSession()
+ popupSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onCloseRequest(session: GeckoSession) {
+ onCloseRequestResult.complete(null)
+ }
+ })
+
+ val actionResult = GeckoResult<WebExtension.Action>()
+ testActionApi("""{
+ "action": "setPopup",
+ "popup": "test-popup.html"
+ }""") { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+ actionResult.complete(action)
+ }
+
+ val togglePopup = GeckoResult<Void>()
+ val action = sessionRule.waitForResult(actionResult)
+ extension!!.setActionDelegate(object : WebExtension.ActionDelegate {
+ override fun onTogglePopup(extension: WebExtension,
+ popupAction: WebExtension.Action): GeckoResult<GeckoSession>? {
+ assertEquals(extension, this@ExtensionActionTest.extension)
+ assertEquals(popupAction, action)
+ togglePopup.complete(null)
+ return GeckoResult.fromValue(popupSession)
+ }
+ })
+ action.click()
+ sessionRule.waitForResult(togglePopup)
+
+ sessionRule.waitForResult(onCloseRequestResult)
+ }
+}
+
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt
new file mode 100644
index 0000000000..6336157237
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt
@@ -0,0 +1,165 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoSession
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class FinderTest : BaseSessionTest() {
+
+ @Test fun find() {
+ mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // Initial search.
+ var result = sessionRule.waitForResult(mainSession.finder.find("dolore", 0))
+
+ assertThat("Should be found", result.found, equalTo(true))
+ assertThat("Should not have wrapped", result.wrapped, equalTo(false))
+ assertThat("Current count should be correct", result.current, equalTo(1))
+ assertThat("Total count should be correct", result.total, equalTo(2))
+ assertThat("Search string should be correct",
+ result.searchString, equalTo("dolore"))
+ assertThat("Flags should be correct", result.flags, equalTo(0))
+
+ // Search again using new flags.
+ result = sessionRule.waitForResult(mainSession.finder.find(
+ null, GeckoSession.FINDER_FIND_BACKWARDS
+ or GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD))
+
+ assertThat("Should be found", result.found, equalTo(true))
+ assertThat("Should have wrapped", result.wrapped, equalTo(true))
+ assertThat("Current count should be correct", result.current, equalTo(2))
+ assertThat("Total count should be correct", result.total, equalTo(2))
+ assertThat("Search string should be correct",
+ result.searchString, equalTo("dolore"))
+ assertThat("Flags should be correct", result.flags,
+ equalTo(GeckoSession.FINDER_FIND_BACKWARDS
+ or GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD))
+
+ // And again using same flags.
+ result = sessionRule.waitForResult(mainSession.finder.find(
+ null, GeckoSession.FINDER_FIND_BACKWARDS
+ or GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD))
+
+ assertThat("Should be found", result.found, equalTo(true))
+ assertThat("Should not have wrapped", result.wrapped, equalTo(false))
+ assertThat("Current count should be correct", result.current, equalTo(1))
+ assertThat("Total count should be correct", result.total, equalTo(2))
+ assertThat("Search string should be correct",
+ result.searchString, equalTo("dolore"))
+ assertThat("Flags should be correct", result.flags,
+ equalTo(GeckoSession.FINDER_FIND_BACKWARDS
+ or GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD))
+
+ // And again but go forward.
+ result = sessionRule.waitForResult(mainSession.finder.find(
+ null, GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD))
+
+ assertThat("Should be found", result.found, equalTo(true))
+ assertThat("Should not have wrapped", result.wrapped, equalTo(false))
+ assertThat("Current count should be correct", result.current, equalTo(2))
+ assertThat("Total count should be correct", result.total, equalTo(2))
+ assertThat("Search string should be correct",
+ result.searchString, equalTo("dolore"))
+ assertThat("Flags should be correct", result.flags,
+ equalTo(GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD))
+ }
+
+ @Test fun find_notFound() {
+ mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ var result = sessionRule.waitForResult(mainSession.finder.find("foo", 0))
+
+ assertThat("Should not be found", result.found, equalTo(false))
+ assertThat("Should have wrapped", result.wrapped, equalTo(true))
+ assertThat("Current count should be correct", result.current, equalTo(0))
+ assertThat("Total count should be correct", result.total, equalTo(0))
+ assertThat("Search string should be correct",
+ result.searchString, equalTo("foo"))
+ assertThat("Flags should be correct", result.flags, equalTo(0))
+
+ result = sessionRule.waitForResult(mainSession.finder.find("lore", 0))
+
+ assertThat("Should be found", result.found, equalTo(true))
+ }
+
+ @Test fun find_matchCase() {
+ mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ var result = sessionRule.waitForResult(mainSession.finder.find("lore", 0))
+
+ assertThat("Total count should be correct", result.total, equalTo(3))
+
+ result = sessionRule.waitForResult(mainSession.finder.find(
+ null, GeckoSession.FINDER_FIND_MATCH_CASE))
+
+ assertThat("Total count should be correct", result.total, equalTo(2))
+ assertThat("Flags should be correct",
+ result.flags, equalTo(GeckoSession.FINDER_FIND_MATCH_CASE))
+ }
+
+ @Test fun find_wholeWord() {
+ mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ var result = sessionRule.waitForResult(mainSession.finder.find("dolor", 0))
+
+ assertThat("Total count should be correct", result.total, equalTo(4))
+
+ result = sessionRule.waitForResult(mainSession.finder.find(
+ null, GeckoSession.FINDER_FIND_WHOLE_WORD))
+
+ assertThat("Total count should be correct", result.total, equalTo(2))
+ assertThat("Flags should be correct",
+ result.flags, equalTo(GeckoSession.FINDER_FIND_WHOLE_WORD))
+ }
+
+ @Test fun find_linksOnly() {
+ mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val result = sessionRule.waitForResult(mainSession.finder.find(
+ "nim", GeckoSession.FINDER_FIND_LINKS_ONLY))
+
+ assertThat("Total count should be correct", result.total, equalTo(1))
+ assertThat("Flags should be correct",
+ result.flags, equalTo(GeckoSession.FINDER_FIND_LINKS_ONLY))
+ }
+
+ @Test fun clear() {
+ mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val result = sessionRule.waitForResult(mainSession.finder.find("lore", 0))
+
+ assertThat("Match should be found", result.found, equalTo(true))
+
+ assertThat("Match should be selected",
+ mainSession.evaluateJS("window.getSelection().toString()") as String,
+ equalTo("Lore"))
+
+ mainSession.finder.clear()
+
+ assertThat("Match should be cleared",
+ mainSession.evaluateJS("window.getSelection().isCollapsed") as Boolean,
+ equalTo(true))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java
new file mode 100644
index 0000000000..0383c2badc
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java
@@ -0,0 +1,546 @@
+package org.mozilla.geckoview.test;
+
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.test.util.Environment;
+import org.mozilla.geckoview.test.util.UiThreadUtils;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.filters.MediumTest;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.assertThat;
+
+@RunWith(AndroidJUnit4.class)
+@MediumTest
+public class GeckoResultTest {
+ private static class MockException extends RuntimeException {
+ }
+
+ private boolean mDone;
+
+ private final Environment mEnv = new Environment();
+
+ private void waitUntilDone() {
+ assertThat("We should not be done", mDone, equalTo(false));
+ UiThreadUtils.waitForCondition(() -> mDone, mEnv.getDefaultTimeoutMillis());
+ }
+
+ private void done() {
+ UiThreadUtils.HANDLER.post(() -> mDone = true);
+ }
+
+ @Before
+ public void setup() {
+ mDone = false;
+ }
+
+ @Test
+ @UiThreadTest
+ public void thenWithResult() {
+ GeckoResult.fromValue(42).accept(value -> {
+ assertThat("Value should match", value, equalTo(42));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void thenWithException() {
+ final Throwable boom = new Exception("boom");
+ GeckoResult.fromException(boom).accept(null, error -> {
+ assertThat("Exception should match", error, equalTo(boom));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ @UiThreadTest
+ public void thenNoListeners() {
+ GeckoResult.fromValue(42).then(null, null);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testCopy() {
+ final GeckoResult<Integer> result = new GeckoResult<>(GeckoResult.fromValue(42));
+ result.accept(value -> {
+ assertThat("Value should match", value, equalTo(42));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void allOfError() throws Throwable {
+ final GeckoResult<List<Integer>> result = GeckoResult.allOf(
+ new GeckoResult<>(GeckoResult.fromValue(12)),
+ new GeckoResult<>(GeckoResult.fromValue(35)),
+ new GeckoResult<>(GeckoResult.fromException(
+ new RuntimeException("Sorry not sorry"))),
+ new GeckoResult<>(GeckoResult.fromValue(0)));
+
+ UiThreadUtils.waitForResult(result.accept(
+ value -> { throw new AssertionError("result should fail"); },
+ error -> {
+ assertThat("Error should match", error instanceof RuntimeException, is(true));
+ assertThat("Error should match", error.getMessage(), equalTo("Sorry not sorry"));
+ }), mEnv.getDefaultTimeoutMillis());
+ }
+
+ @Test
+ @UiThreadTest
+ public void allOfEmpty() {
+ final GeckoResult<List<Integer>> result = GeckoResult.allOf();
+
+ result.accept(value -> {
+ assertThat("Value should match", value.isEmpty(), is(true));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void allOfNull() {
+ final GeckoResult<List<Integer>> result = GeckoResult.allOf(
+ (List<GeckoResult<Integer>>) null);
+
+ result.accept(value -> {
+ assertThat("Value should match", value, equalTo(null));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void allOfMany() {
+ final GeckoResult<Integer> pending1 = new GeckoResult<>();
+ final GeckoResult<Integer> pending2 = new GeckoResult<>();
+
+ final GeckoResult<List<Integer>> result = GeckoResult.allOf(
+ pending1,
+ new GeckoResult<>(GeckoResult.fromValue(12)),
+ pending2,
+ new GeckoResult<>(GeckoResult.fromValue(35)),
+ new GeckoResult<>(GeckoResult.fromValue(9)),
+ new GeckoResult<>(GeckoResult.fromValue(0)));
+
+ result.accept(value -> {
+ assertThat("Value should match", value, equalTo(
+ Arrays.asList(123, 12, 321, 35, 9, 0)));
+ done();
+ });
+
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException ex) {
+ }
+
+ // Complete the results out of order so that we can verify the input order is preserved
+ pending2.complete(321);
+ pending1.complete(123);
+ waitUntilDone();
+ }
+
+ @Test(expected = IllegalStateException.class)
+ @UiThreadTest
+ public void completeMultiple() {
+ final GeckoResult<Integer> deferred = new GeckoResult<>();
+ deferred.complete(42);
+ deferred.complete(43);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ @UiThreadTest
+ public void completeMultipleExceptions() {
+ final GeckoResult<Integer> deferred = new GeckoResult<>();
+ deferred.completeExceptionally(new Exception("boom"));
+ deferred.completeExceptionally(new Exception("boom again"));
+ }
+
+ @Test(expected = IllegalStateException.class)
+ @UiThreadTest
+ public void completeMixed() {
+ final GeckoResult<Integer> deferred = new GeckoResult<>();
+ deferred.complete(42);
+ deferred.completeExceptionally(new Exception("boom again"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ @UiThreadTest
+ public void completeExceptionallyNull() {
+ new GeckoResult<Integer>().completeExceptionally(null);
+ }
+
+ @Test
+ @UiThreadTest
+ public void completeThreaded() {
+ final GeckoResult<Integer> deferred = new GeckoResult<>();
+ final Thread thread = new Thread(() -> deferred.complete(42));
+
+ deferred.accept(value -> {
+ assertThat("Value should match", value, equalTo(42));
+ ThreadUtils.assertOnUiThread();
+ done();
+ });
+
+ thread.start();
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void dispatchOnInitialThread() throws InterruptedException {
+ final Thread thread = new Thread(() -> {
+ Looper.prepare();
+ final Thread dispatchThread = Thread.currentThread();
+
+ GeckoResult.fromValue(42).accept(value -> {
+ assertThat("Thread should match", Thread.currentThread(),
+ equalTo(dispatchThread));
+ Looper.myLooper().quit();
+ });
+
+ Looper.loop();
+ });
+
+ thread.start();
+ thread.join();
+ }
+
+ @Test
+ @UiThreadTest
+ public void completeExceptionallyThreaded() {
+ final GeckoResult<Integer> deferred = new GeckoResult<>();
+ final Throwable boom = new Exception("boom");
+ final Thread thread = new Thread(() -> deferred.completeExceptionally(boom));
+
+ deferred.exceptionally(error -> {
+ assertThat("Exception should match", error, equalTo(boom));
+ ThreadUtils.assertOnUiThread();
+ done();
+ return null;
+ });
+
+ thread.start();
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void resultMapChaining() {
+ assertThat("We're on the UI thread", Thread.currentThread(), equalTo(Looper.getMainLooper().getThread()));
+
+ GeckoResult.fromValue(42).map(value -> {
+ assertThat("Value should match", value, equalTo(42));
+ return "hello";
+ }).map(value -> {
+ assertThat("Value should match", value, equalTo("hello"));
+ return 42.0f;
+ }).map(value -> {
+ assertThat("Value should match", value, equalTo(42.0f));
+ throw new Exception("boom");
+ }).map(null, error -> {
+ assertThat("Error message should match", error.getMessage(), equalTo("boom"));
+ return new MockException();
+ }).accept(null, exception -> {
+ assertThat("Exception should be MockException", exception, instanceOf(MockException.class));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void resultChaining() {
+ assertThat("We're on the UI thread", Thread.currentThread(), equalTo(Looper.getMainLooper().getThread()));
+
+ GeckoResult.fromValue(42).then(value -> {
+ assertThat("Value should match", value, equalTo(42));
+ return GeckoResult.fromValue("hello");
+ }).then(value -> {
+ assertThat("Value should match", value, equalTo("hello"));
+ return GeckoResult.fromValue(42.0f);
+ }).then(value -> {
+ assertThat("Value should match", value, equalTo(42.0f));
+ return GeckoResult.fromException(new Exception("boom"));
+ }).exceptionally(error -> {
+ assertThat("Error message should match", error.getMessage(), equalTo("boom"));
+ throw new MockException();
+ }).accept(null, exception -> {
+ assertThat("Exception should be MockException", exception, instanceOf(MockException.class));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void then_propagatedValue() {
+ // The first GeckoResult only has an exception listener, so when the value 42 is
+ // propagated to subsequent GeckoResult instances, the propagated value is coerced to null.
+ GeckoResult.fromValue(42).exceptionally(error -> null)
+ .accept(value -> {
+ assertThat("Propagated value is null", value, nullValue());
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test(expected = GeckoResult.UncaughtException.class)
+ public void then_uncaughtException() {
+ GeckoResult.fromValue(42).then(value -> {
+ throw new MockException();
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test(expected = GeckoResult.UncaughtException.class)
+ public void then_propagatedUncaughtException() {
+ GeckoResult.fromValue(42).then(value -> {
+ throw new MockException();
+ }).accept(value -> {});
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void then_caughtException() {
+ GeckoResult.fromValue(42).then(value -> { throw new MockException(); })
+ .accept(value -> {})
+ .exceptionally(exception -> {
+ assertThat("Exception should be expected",
+ exception, instanceOf(MockException.class));
+ done();
+ return null;
+ });
+
+ waitUntilDone();
+ }
+
+ @Test(expected = IllegalThreadStateException.class)
+ public void noLooperThenThrows() {
+ assertThat("We shouldn't have a Looper", Looper.myLooper(), nullValue());
+ GeckoResult.fromValue(42).then(value -> null);
+ }
+
+ @Test
+ public void noLooperPoll() throws Throwable {
+ assertThat("We shouldn't have a Looper", Looper.myLooper(), nullValue());
+ assertThat("Value should match",
+ GeckoResult.fromValue(42).poll(0), equalTo(42));
+ }
+
+ @Test
+ public void withHandler() {
+
+ final SynchronousQueue<Handler> queue = new SynchronousQueue<>();
+ final Thread thread = new Thread(() -> {
+ Looper.prepare();
+
+ try {
+ queue.put(new Handler());
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+
+ Looper.loop();
+ });
+
+ thread.start();
+
+ final GeckoResult<Integer> result = GeckoResult.fromValue(42);
+ assertThat("We shouldn't have a Looper", result.getLooper(), nullValue());
+
+ try {
+ result.withHandler(queue.take()).accept(value -> {
+ assertThat("Thread should match", Thread.currentThread(), equalTo(thread));
+ assertThat("Value should match", value, equalTo(42));
+ Looper.myLooper().quit();
+ });
+
+ thread.join();
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ public void pollCompleteWithValue() throws Throwable {
+ assertThat("Value should match",
+ GeckoResult.fromValue(42).poll(0), equalTo(42));
+ }
+
+ @Test(expected = MockException.class)
+ public void pollCompleteWithError() throws Throwable {
+ GeckoResult.fromException(new MockException()).poll(0);
+ }
+
+
+ @Test(expected = TimeoutException.class)
+ public void pollTimeout() throws Throwable {
+ new GeckoResult<Void>().poll(1);
+ }
+
+ @UiThreadTest
+ @Test(expected = TimeoutException.class)
+ public void pollTimeoutWithLooper() throws Throwable {
+ new GeckoResult<Void>().poll(1);
+ }
+
+ @UiThreadTest
+ @Test(expected = IllegalThreadStateException.class)
+ public void pollWithLooper() throws Throwable {
+ new GeckoResult<Void>().poll();
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelNoDelegate() {
+ GeckoResult<Void> result = new GeckoResult<Void>();
+ result.cancel().accept(value -> {
+ assertThat("Cancellation should fail", value, equalTo(false));
+ done();
+ });
+ waitUntilDone();
+ }
+
+ private GeckoResult<Integer> createCancellableResult() {
+ GeckoResult<Integer> result = new GeckoResult<>();
+ result.setCancellationDelegate(new GeckoResult.CancellationDelegate() {
+ @Override
+ public GeckoResult<Boolean> cancel() {
+ return GeckoResult.fromValue(true);
+ }
+ });
+
+ return result;
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelSuccess() {
+ GeckoResult<Integer> result = createCancellableResult();
+
+ result.cancel().accept(value -> {
+ assertThat("Cancel should succeed", value, equalTo(true));
+ result.exceptionally(exception -> {
+ assertThat("Exception should match", exception, instanceOf(CancellationException.class));
+ done();
+
+ return null;
+ });
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelCompleted() {
+ GeckoResult<Integer> result = createCancellableResult();
+ result.complete(42);
+ result.cancel().accept(value -> {
+ assertThat("Cancel should fail", value, equalTo(false));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelParent() {
+ GeckoResult<Integer> result = createCancellableResult();
+ GeckoResult<Integer> result2 = result.then(value -> GeckoResult.fromValue(42));
+
+ result.cancel().accept(value -> {
+ assertThat("Cancel should succeed", value, equalTo(true));
+ result2.exceptionally(exception -> {
+ assertThat("Exception should match", exception, instanceOf(CancellationException.class));
+ done();
+
+ return null;
+ });
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelChildParentNotComplete() {
+ GeckoResult<Integer> result = new GeckoResult<Integer>()
+ .then(value -> createCancellableResult())
+ .then(value -> new GeckoResult<Integer>());
+
+ result.cancel().accept(value -> {
+ assertThat("Cancel should fail", value, equalTo(false));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelChildParentComplete() {
+ final GeckoResult<Integer> result = GeckoResult.fromValue(42)
+ .then(value -> createCancellableResult())
+ .then(value -> new GeckoResult<Integer>());
+
+ final Handler handler = new Handler();
+ handler.post(() -> {
+ result.cancel().accept(value -> {
+ assertThat("Cancel should succeed", value, equalTo(true));
+ done();
+ });
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void getOrAccept() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
+ final Method ai = GeckoResult.class.getDeclaredMethod("getOrAccept", GeckoResult.Consumer.class);
+ ai.setAccessible(true);
+
+ final AtomicBoolean ran = new AtomicBoolean(false);
+ ai.invoke(GeckoResult.fromValue(42), (GeckoResult.Consumer<Integer>) o -> ran.set(true));
+ assertThat("Should've ran", ran.get(), equalTo(true));
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt
new file mode 100644
index 0000000000..59a29a3292
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt
@@ -0,0 +1,37 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+package org.mozilla.geckoview.test
+
+import org.junit.Test
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.test.util.Environment
+
+import org.hamcrest.Matchers.*
+import org.junit.Assert.assertThat
+
+val env = Environment()
+
+fun <T> GeckoResult<T>.pollDefault(): T? =
+ this.poll(env.defaultTimeoutMillis)
+
+class GeckoResultTestKotlin {
+ class MockException : RuntimeException()
+
+ @Test fun pollIncompleteWithValue() {
+ val result = GeckoResult<Int>()
+ val thread = Thread { result.complete(42) }
+
+ thread.start()
+ assertThat("Value should match", result.pollDefault(), equalTo(42))
+ }
+
+ @Test(expected = MockException::class) fun pollIncompleteWithError() {
+ val result = GeckoResult<Void>()
+
+ val thread = Thread { result.completeExceptionally(MockException()) }
+ thread.start()
+
+ result.pollDefault()
+ }
+} \ No newline at end of file
diff --git 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
new file mode 100644
index 0000000000..5037ed8c49
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
@@ -0,0 +1,1737 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.os.Handler
+import android.os.Looper
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSessionSettings
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.*
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.hamcrest.Matchers.*
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Test for the GeckoSessionTestRule class, to ensure it properly sets up a session for
+ * each test, and to ensure it can properly wait for and assert delegate
+ * callbacks.
+ */
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class GeckoSessionTestRuleTest : BaseSessionTest(noErrorCollector = true) {
+
+ @Test fun getSession() {
+ assertThat("Can get session", sessionRule.session, notNullValue())
+ assertThat("Session is open",
+ sessionRule.session.isOpen, equalTo(true))
+ }
+
+ @ClosedSessionAtStart
+ @Test fun getSession_closedSession() {
+ assertThat("Session is closed", sessionRule.session.isOpen, equalTo(false))
+ }
+
+ @Setting.List(Setting(key = Setting.Key.USE_PRIVATE_MODE, value = "true"),
+ Setting(key = Setting.Key.DISPLAY_MODE, value = "DISPLAY_MODE_MINIMAL_UI"),
+ Setting(key = Setting.Key.ALLOW_JAVASCRIPT, value = "false"))
+ @Setting(key = Setting.Key.USE_TRACKING_PROTECTION, value = "true")
+ @Test fun settingsApplied() {
+ assertThat("USE_PRIVATE_MODE should be set",
+ sessionRule.session.settings.usePrivateMode,
+ equalTo(true))
+ assertThat("DISPLAY_MODE should be set",
+ sessionRule.session.settings.displayMode,
+ equalTo(GeckoSessionSettings.DISPLAY_MODE_MINIMAL_UI))
+ assertThat("USE_TRACKING_PROTECTION should be set",
+ sessionRule.session.settings.useTrackingProtection,
+ equalTo(true))
+ assertThat("ALLOW_JAVASCRIPT should be set",
+ sessionRule.session.settings.allowJavascript,
+ equalTo(false))
+ }
+
+ @Test(expected = UiThreadUtils.TimeoutException::class)
+ @TimeoutMillis(2000)
+ fun noPendingCallbacks() {
+ // Make sure we don't have unexpected pending callbacks at the start of a test.
+ sessionRule.waitUntilCalled(object : Callbacks.All {
+ // There may be extraneous onSessionStateChange and onHistoryStateChange calls
+ // after a test, so ignore the first received.
+ @AssertCalled(count = 2)
+ override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+ }
+
+ @AssertCalled(count = 2)
+ override fun onHistoryStateChange(session: GeckoSession, historyList: GeckoSession.HistoryDelegate.HistoryList) {
+ }
+ })
+ }
+
+ @Test fun includesAllCallbacks() {
+ for (ifce in GeckoSession::class.java.classes) {
+ if (!ifce.isInterface || !ifce.simpleName.endsWith("Delegate")) {
+ continue
+ }
+ assertThat("Callbacks.All should include interface " + ifce.simpleName,
+ ifce.isInstance(Callbacks.Default), equalTo(true))
+ }
+ }
+
+ @NullDelegate.List(NullDelegate(GeckoSession.ContentDelegate::class),
+ NullDelegate(Callbacks.NavigationDelegate::class))
+ @NullDelegate(Callbacks.ScrollDelegate::class)
+ @Test fun nullDelegate() {
+ assertThat("Content delegate should be null",
+ sessionRule.session.contentDelegate, nullValue())
+ assertThat("Navigation delegate should be null",
+ sessionRule.session.navigationDelegate, nullValue())
+ assertThat("Scroll delegate should be null",
+ sessionRule.session.scrollDelegate, nullValue())
+
+ assertThat("Progress delegate should not be null",
+ sessionRule.session.progressDelegate, notNullValue())
+ }
+
+ @NullDelegate(GeckoSession.ProgressDelegate::class)
+ @ClosedSessionAtStart
+ @Test fun nullDelegate_closed() {
+ assertThat("Progress delegate should be null",
+ sessionRule.session.progressDelegate, nullValue())
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(GeckoSession.ProgressDelegate::class)
+ @ClosedSessionAtStart
+ fun nullDelegate_requireProgressOnOpen() {
+ assertThat("Progress delegate should be null",
+ sessionRule.session.progressDelegate, nullValue())
+
+ sessionRule.session.open()
+ }
+
+ @Test fun waitForPageStop() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test(expected = AssertionError::class)
+ fun waitForPageStop_throwOnChangedCallback() {
+ sessionRule.session.progressDelegate = Callbacks.Default
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test fun waitForPageStops() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(GeckoSession.ProgressDelegate::class)
+ @ClosedSessionAtStart
+ fun waitForPageStops_throwOnNullDelegate() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.open(sessionRule.runtime) // Avoid waiting for initial load
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStops(2)
+ }
+
+ @Test fun waitUntilCalled_anyInterfaceMethod() {
+ // TODO: Bug 1673953
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(GeckoSession.ProgressDelegate::class)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ counter++
+ }
+
+ override fun onProgressChange(session: GeckoSession, progress: Int) {
+ counter++
+ }
+
+ override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun waitUntilCalled_specificInterfaceMethod() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(GeckoSession.ProgressDelegate::class,
+ "onPageStart", "onPageStop")
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test(expected = AssertionError::class)
+ fun waitUntilCalled_throwOnNotGeckoSessionInterface() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(CharSequence::class)
+ }
+
+ fun waitUntilCalled_notThrowOnCallbackInterface() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(Callbacks.ProgressDelegate::class)
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(GeckoSession.ScrollDelegate::class)
+ fun waitUntilCalled_throwOnNullDelegateInterface() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.reload()
+ sessionRule.session.waitUntilCalled(Callbacks.All::class)
+ }
+
+ @NullDelegate(GeckoSession.ScrollDelegate::class)
+ @Test fun waitUntilCalled_notThrowOnNonNullDelegateMethod() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.reload()
+ sessionRule.session.waitUntilCalled(Callbacks.All::class, "onPageStop")
+ }
+
+ @Test fun waitUntilCalled_anyObjectMethod() {
+ // TODO: Bug 1673953
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ counter++
+ }
+
+ override fun onProgressChange(session: GeckoSession, progress: Int) {
+ counter++
+ }
+
+ override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun waitUntilCalled_specificObjectMethod() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(GeckoSession.ScrollDelegate::class)
+ fun waitUntilCalled_throwOnNullDelegateObject() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.reload()
+ sessionRule.session.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @NullDelegate(GeckoSession.ScrollDelegate::class)
+ @Test fun waitUntilCalled_notThrowOnNonNullDelegateObject() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.reload()
+ sessionRule.session.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_multipleCount() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled(count = 2)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(4))
+ }
+
+ @Test fun waitUntilCalled_currentCall() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2, order = [1, 2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ val info = sessionRule.currentCall
+ assertThat("Method info should be valid", info, notNullValue())
+ assertThat("Counter should be correct",
+ info.counter, equalTo(forEachCall(1, 2)))
+ assertThat("Order should equal counter",
+ info.order, equalTo(info.counter))
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun waitUntilCalled_passThroughExceptions() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ throw IllegalStateException()
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_zeroCount() {
+ // Support having @AssertCalled(count = 0) annotations for waitUntilCalled calls.
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate, Callbacks.ScrollDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+
+ @AssertCalled(count = 0)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_anyMethod() {
+ // TODO: Bug 1673953
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnAnyMethodNotCalled() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : GeckoSession.ScrollDelegate {})
+ }
+
+ @Test fun forCallbacksDuringWait_specificMethod() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test fun forCallbacksDuringWait_specificMethodMultipleTimes() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(4))
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnSpecificMethodNotCalled() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : GeckoSession.ScrollDelegate {
+ @AssertCalled
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_specificCount() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled(count = 2)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(4))
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnWrongCount() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_specificOrder() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnWrongOrder() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(order = [2])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(order = [1])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_multipleOrder() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(order = [1, 3, 1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(order = [2, 4, 1])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnWrongMultipleOrder() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(order = [1, 2, 1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(order = [3, 4, 1])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_notCalled() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : GeckoSession.ScrollDelegate {
+ @AssertCalled(false)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnCallingNoCall() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_zeroCountEqualsNotCalled() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : GeckoSession.ScrollDelegate {
+ @AssertCalled(count = 0)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnCallingZeroCount() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 0)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_limitedToLastWait() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+ sessionRule.session.reload()
+ sessionRule.session.reload()
+
+ // Wait for Gecko to finish all loads.
+ Thread.sleep(100)
+
+ sessionRule.waitForPageStop() // Wait for loadUri.
+ sessionRule.waitForPageStop() // Wait for first reload.
+
+ var counter = 0
+
+ // assert should only apply to callbacks within range (loadUri, first reload].
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test fun forCallbacksDuringWait_currentCall() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ val info = sessionRule.currentCall
+ assertThat("Method info should be valid", info, notNullValue())
+ assertThat("Counter should be correct",
+ info.counter, equalTo(1))
+ assertThat("Order should equal counter",
+ info.order, equalTo(0))
+ }
+ })
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun forCallbacksDuringWait_passThroughExceptions() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ throw IllegalStateException()
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(GeckoSession.ScrollDelegate::class)
+ fun forCallbacksDuringWait_throwOnAnyNullDelegate() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.All {})
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(GeckoSession.ScrollDelegate::class)
+ fun forCallbacksDuringWait_throwOnSpecificNullDelegate() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.All {
+ @AssertCalled
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @NullDelegate(GeckoSession.ScrollDelegate::class)
+ @Test fun forCallbacksDuringWait_notThrowOnNonNullDelegate() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.All {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun getCurrentCall_throwOnNoCurrentCall() {
+ sessionRule.currentCall
+ }
+
+ @Test fun delegateUntilTestEnd() {
+ var counter = 0
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test fun delegateUntilTestEnd_notCalled() {
+ sessionRule.delegateUntilTestEnd(object : GeckoSession.ScrollDelegate {
+ @AssertCalled(false)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateUntilTestEnd_throwOnNotCalled() {
+ sessionRule.delegateUntilTestEnd(object : GeckoSession.ScrollDelegate {
+ @AssertCalled(count = 1)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ sessionRule.performTestEndCheck()
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateUntilTestEnd_throwOnCallingNoCall() {
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateUntilTestEnd_throwOnWrongOrder() {
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test fun delegateUntilTestEnd_currentCall() {
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ val info = sessionRule.currentCall
+ assertThat("Method info should be valid", info, notNullValue())
+ assertThat("Counter should be correct",
+ info.counter, equalTo(1))
+ assertThat("Order should equal counter",
+ info.order, equalTo(0))
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test fun delegateDuringNextWait() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ var counter = 0
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("Should have delegated", counter, equalTo(2))
+
+ sessionRule.session.reload()
+ sessionRule.waitForPageStop()
+
+ assertThat("Delegate should be cleared", counter, equalTo(2))
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateDuringNextWait_throwOnNotCalled() {
+ sessionRule.delegateDuringNextWait(object : GeckoSession.ScrollDelegate {
+ @AssertCalled(count = 1)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateDuringNextWait_throwOnNotCalledAtTestEnd() {
+ sessionRule.delegateDuringNextWait(object : GeckoSession.ScrollDelegate {
+ @AssertCalled(count = 1)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ sessionRule.performTestEndCheck()
+ }
+
+ @Test fun delegateDuringNextWait_hasPrecedence() {
+ var testCounter = 0
+ var waitCounter = 0
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate,
+ Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ testCounter++
+ }
+
+ @AssertCalled(count = 1, order = [4])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ testCounter++
+ }
+
+ @AssertCalled(count = 2, order = [1, 3])
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ testCounter++
+ }
+
+ @AssertCalled(count = 2, order = [1, 3])
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ testCounter++
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ waitCounter++
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ waitCounter++
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("Text delegate should be overridden",
+ testCounter, equalTo(2))
+ assertThat("Wait delegate should be used", waitCounter, equalTo(2))
+
+ sessionRule.session.reload()
+ sessionRule.waitForPageStop()
+
+ assertThat("Test delegate should be used", testCounter, equalTo(6))
+ assertThat("Wait delegate should be cleared", waitCounter, equalTo(2))
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun delegateDuringNextWait_passThroughExceptions() {
+ sessionRule.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ throw IllegalStateException()
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(GeckoSession.NavigationDelegate::class)
+ fun delegateDuringNextWait_throwOnNullDelegate() {
+ sessionRule.session.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ }
+ })
+ }
+
+ @Test fun wrapSession() {
+ val session = sessionRule.wrapSession(
+ GeckoSession(sessionRule.session.settings))
+ sessionRule.openSession(session)
+ session.reload()
+ session.waitForPageStop()
+ }
+
+ @Test fun createOpenSession() {
+ val newSession = sessionRule.createOpenSession()
+ assertThat("Can create session", newSession, notNullValue())
+ assertThat("New session is open", newSession.isOpen, equalTo(true))
+ assertThat("New session has same settings",
+ newSession.settings, equalTo(sessionRule.session.settings))
+ }
+
+ @Test fun createOpenSession_withSettings() {
+ val settings = GeckoSessionSettings.Builder(sessionRule.session.settings)
+ .usePrivateMode(true)
+ .build()
+
+ val newSession = sessionRule.createOpenSession(settings)
+ assertThat("New session has same settings", newSession.settings, equalTo(settings))
+ }
+
+ @Test fun createOpenSession_canInterleaveOtherCalls() {
+ // TODO: Bug 1673953
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+
+ val newSession = sessionRule.createOpenSession()
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ newSession.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun createClosedSession() {
+ val newSession = sessionRule.createClosedSession()
+ assertThat("Can create session", newSession, notNullValue())
+ assertThat("New session is open", newSession.isOpen, equalTo(false))
+ assertThat("New session has same settings",
+ newSession.settings, equalTo(sessionRule.session.settings))
+ }
+
+ @Test fun createClosedSession_withSettings() {
+ val settings = GeckoSessionSettings.Builder(sessionRule.session.settings).usePrivateMode(true).build()
+
+ val newSession = sessionRule.createClosedSession(settings)
+ assertThat("New session has same settings", newSession.settings, equalTo(settings))
+ }
+
+ @Test(expected = UiThreadUtils.TimeoutException::class)
+ @TimeoutMillis(2000)
+ @ClosedSessionAtStart
+ fun noPendingCallbacks_withSpecificSession() {
+ sessionRule.createOpenSession()
+ // Make sure we don't have unexpected pending callbacks after opening a session.
+ sessionRule.waitUntilCalled(object : Callbacks.All {
+ // There may be extraneous onSessionStateChange and onHistoryStateChange calls
+ // after a test, so ignore the first received.
+ @AssertCalled(count = 2)
+ override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+ }
+
+ @AssertCalled(count = 2)
+ override fun onHistoryStateChange(session: GeckoSession, historyList: GeckoSession.HistoryDelegate.HistoryList) {
+ }
+ })
+ }
+
+ @Test fun waitForPageStop_withSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+ }
+
+ @Test fun waitForPageStop_withAllSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ fun waitForPageStop_throwOnNotWrapped() {
+ GeckoSession(sessionRule.session.settings).waitForPageStop()
+ }
+
+ @Test fun waitForPageStops_withSpecificSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.reload()
+ newSession.waitForPageStops(2)
+ }
+
+ @Test fun waitForPageStops_withAllSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+ }
+
+ @Test fun waitForPageStops_acrossSessionCreation() {
+ // TODO: Bug 1673953
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ val session = sessionRule.createOpenSession()
+ sessionRule.session.reload()
+ session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(3)
+ }
+
+ @Test fun waitUntilCalled_interfaceWithSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitUntilCalled(Callbacks.ProgressDelegate::class, "onPageStop")
+ }
+
+ @Test fun waitUntilCalled_interfaceWithAllSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(Callbacks.ProgressDelegate::class, "onPageStop")
+ }
+
+ @Test fun waitUntilCalled_callbackWithSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_callbackWithAllSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_withSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+
+ var counter = 0
+
+ newSession.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun forCallbacksDuringWait_withAllSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test fun forCallbacksDuringWait_limitedToLastSessionWait() {
+ val newSession = sessionRule.createOpenSession()
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+
+ // forCallbacksDuringWait calls strictly apply to the last wait, session-specific or not.
+ var counter = 0
+
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test fun delegateUntilTestEnd_withSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+
+ var counter = 0
+
+ newSession.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.session.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun delegateUntilTestEnd_withAllSessions() {
+ var counter = 0
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun delegateDuringNextWait_hasPrecedenceWithSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+ var counter = 0
+
+ newSession.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ 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() {
+ val newSession = sessionRule.createOpenSession()
+ var counter = 0
+
+ newSession.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test fun synthesizeTap() {
+ sessionRule.session.loadTestPath(CLICK_TO_RELOAD_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.synthesizeTap(50, 50)
+ sessionRule.session.waitForPageStop()
+ }
+
+ @Test fun evaluateExtensionJS() {
+ assertThat("JS string result should be correct",
+ sessionRule.evaluateExtensionJS("return 'foo';") as String, equalTo("foo"))
+
+ assertThat("JS number result should be correct",
+ sessionRule.evaluateExtensionJS("return 1+1;") as Double, equalTo(2.0))
+
+ assertThat("JS boolean result should be correct",
+ sessionRule.evaluateExtensionJS("return !0;") as Boolean, equalTo(true))
+
+ val expected = JSONObject("{bar:42,baz:true,foo:'bar'}")
+ val actual = sessionRule.evaluateExtensionJS("return {foo:'bar',bar:42,baz:true};") as JSONObject
+ for (key in expected.keys()) {
+ assertThat("JS object result should be correct",
+ actual.get(key), equalTo(expected.get(key)))
+ }
+
+ assertThat("JS array result should be correct",
+ sessionRule.evaluateExtensionJS("return [1,2,3];") as JSONArray,
+ equalTo(JSONArray("[1,2,3]")))
+
+ assertThat("Can access extension APIS",
+ sessionRule.evaluateExtensionJS("return !!browser.runtime;") as Boolean,
+ equalTo(true))
+
+ assertThat("Can access extension APIS",
+ sessionRule.evaluateExtensionJS("""
+ return true;
+ // Comments at the end are allowed""".trimIndent()) as Boolean,
+ equalTo(true))
+
+ try {
+ sessionRule.evaluateExtensionJS("test({ what")
+ assertThat("Should fail", true, equalTo(false))
+ } catch (e: RejectedPromiseException) {
+ assertThat("Syntax errors are reported",
+ e.message, containsString("SyntaxError"))
+ }
+ }
+
+ @Test fun evaluateJS() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH);
+ sessionRule.session.waitForPageStop();
+
+ assertThat("JS string result should be correct",
+ sessionRule.session.evaluateJS("'foo'") as String, equalTo("foo"))
+
+ assertThat("JS number result should be correct",
+ sessionRule.session.evaluateJS("1+1") as Double, equalTo(2.0))
+
+ assertThat("JS boolean result should be correct",
+ sessionRule.session.evaluateJS("!0") as Boolean, equalTo(true))
+
+ val expected = JSONObject("{bar:42,baz:true,foo:'bar'}")
+ val actual = sessionRule.session.evaluateJS("({foo:'bar',bar:42,baz:true})") as JSONObject
+ for (key in expected.keys()) {
+ assertThat("JS object result should be correct",
+ actual.get(key), equalTo(expected.get(key)))
+ }
+
+ assertThat("JS array result should be correct",
+ sessionRule.session.evaluateJS("[1,2,3]") as JSONArray,
+ equalTo(JSONArray("[1,2,3]")))
+
+ assertThat("JS DOM object result should be correct",
+ sessionRule.session.evaluateJS("document.body.tagName") as String,
+ equalTo("BODY"))
+ }
+
+ @Test fun evaluateJS_windowObject() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ assertThat("JS DOM window result should be correct",
+ (sessionRule.session.evaluateJS("window.location.pathname")) as String,
+ equalTo(HELLO_HTML_PATH))
+ }
+
+ @Test fun evaluateJS_multipleSessions() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("this.foo = 42")
+ assertThat("Variable should be set",
+ sessionRule.session.evaluateJS("this.foo") as Double, equalTo(42.0))
+
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+
+ val result = newSession.evaluateJS("this.foo")
+ assertThat("New session should have separate JS context",
+ result, nullValue())
+ }
+
+ @Test fun evaluateJS_supportPromises() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ assertThat("Can get resolved promise",
+ sessionRule.session.evaluatePromiseJS(
+ "new Promise(resolve => resolve('foo'))").value as String,
+ equalTo("foo"));
+
+ val promise = sessionRule.session.evaluatePromiseJS(
+ "new Promise(r => window.resolve = r)")
+
+ sessionRule.session.evaluateJS("window.resolve('bar')")
+
+ assertThat("Can wait for promise to resolve",
+ promise.value as String, equalTo("bar"))
+ }
+
+ @Test(expected = RejectedPromiseException::class)
+ fun evaluateJS_throwOnRejectedPromise() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+ sessionRule.session.evaluatePromiseJS("Promise.reject('foo')").value
+ }
+
+ @Test fun evaluateJS_notBlockMainThread() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+ // Test that we can still receive delegate callbacks during evaluateJS,
+ // by calling alert(), which blocks until prompt delegate is called.
+ assertThat("JS blocking result should be correct",
+ sessionRule.session.evaluateJS("alert(); 'foo'") as String,
+ equalTo("foo"))
+ }
+
+ @TimeoutMillis(1000)
+ @Test(expected = UiThreadUtils.TimeoutException::class)
+ fun evaluateJS_canTimeout() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+ sessionRule.session.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ override fun onAlertPrompt(session: GeckoSession, prompt: GeckoSession.PromptDelegate.AlertPrompt): GeckoResult<GeckoSession.PromptDelegate.PromptResponse> {
+ // Return a GeckoResult that we will never complete, so it hangs.
+ val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
+ return res
+ }
+ })
+ sessionRule.session.evaluateJS("alert()")
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun evaluateJS_throwOnJSException() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+ sessionRule.session.evaluateJS("throw Error()")
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun evaluateJS_throwOnSyntaxError() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+ sessionRule.session.evaluateJS("<{[")
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun evaluateJS_throwOnChromeAccess() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+ sessionRule.session.evaluateJS("ChromeUtils")
+ }
+
+ @Test fun getPrefs_undefinedPrefReturnsNull() {
+ assertThat("Undefined pref should have null value",
+ sessionRule.getPrefs("invalid.pref")[0], equalTo(JSONObject.NULL))
+ }
+
+ @Test fun setPrefsUntilTestEnd() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "test.pref.bool" to true,
+ "test.pref.int" to 1,
+ "test.pref.foo" to "foo"))
+
+ var prefs = sessionRule.getPrefs(
+ "test.pref.bool",
+ "test.pref.int",
+ "test.pref.foo",
+ "test.pref.bar")
+
+ assertThat("Prefs should be set", prefs[0] as Boolean, equalTo(true))
+ assertThat("Prefs should be set", prefs[1] as Int, equalTo(1))
+ assertThat("Prefs should be set", prefs[2] as String, equalTo("foo"))
+ assertThat("Prefs should be set", prefs[3], equalTo(JSONObject.NULL))
+
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "test.pref.foo" to "bar",
+ "test.pref.bar" to "baz"))
+
+ prefs = sessionRule.getPrefs(
+ "test.pref.bool",
+ "test.pref.int",
+ "test.pref.foo",
+ "test.pref.bar")
+
+ assertThat("New prefs should be set", prefs[0] as Boolean, equalTo(true))
+ assertThat("New prefs should be set", prefs[1] as Int, equalTo(1))
+ assertThat("New prefs should be set", prefs[2] as String, equalTo("bar"))
+ assertThat("New prefs should be set", prefs[3] as String, equalTo("baz"))
+ }
+
+ @Test fun setPrefsDuringNextWait() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.setPrefsDuringNextWait(mapOf(
+ "test.pref.bool" to true,
+ "test.pref.int" to 1,
+ "test.pref.foo" to "foo"))
+
+ var prefs = sessionRule.getPrefs(
+ "test.pref.bool",
+ "test.pref.int",
+ "test.pref.foo")
+
+ assertThat("Prefs should be set before wait", prefs[0] as Boolean, equalTo(true))
+ assertThat("Prefs should be set before wait", prefs[1] as Int, equalTo(1))
+ assertThat("Prefs should be set before wait", prefs[2] as String, equalTo("foo"))
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ prefs = sessionRule.getPrefs(
+ "test.pref.bool",
+ "test.pref.int",
+ "test.pref.foo")
+
+ assertThat("Prefs should be cleared after wait", prefs[0], equalTo(JSONObject.NULL))
+ assertThat("Prefs should be cleared after wait", prefs[1], equalTo(JSONObject.NULL))
+ assertThat("Prefs should be cleared after wait", prefs[2], equalTo(JSONObject.NULL))
+ }
+
+ @Test fun setPrefsDuringNextWait_hasPrecedence() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "test.pref.int" to 1,
+ "test.pref.foo" to "foo"))
+
+ sessionRule.setPrefsDuringNextWait(mapOf(
+ "test.pref.foo" to "bar",
+ "test.pref.bar" to "baz"))
+
+ var prefs = sessionRule.getPrefs(
+ "test.pref.int",
+ "test.pref.foo",
+ "test.pref.bar")
+
+ assertThat("Prefs should be overridden", prefs[0] as Int, equalTo(1))
+ assertThat("Prefs should be overridden", prefs[1] as String, equalTo("bar"))
+ assertThat("Prefs should be overridden", prefs[2] as String, equalTo("baz"))
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ prefs = sessionRule.getPrefs(
+ "test.pref.int",
+ "test.pref.foo",
+ "test.pref.bar")
+
+ assertThat("Overriden prefs should be restored", prefs[0] as Int, equalTo(1))
+ assertThat("Overriden prefs should be restored", prefs[1] as String, equalTo("foo"))
+ assertThat("Overriden prefs should be restored", prefs[2], equalTo(JSONObject.NULL))
+ }
+
+ @Test fun waitForJS() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("waitForJS should return correct result",
+ sessionRule.session.waitForJS("alert(), 'foo'") as String,
+ equalTo("foo"))
+
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onAlertPrompt(session: GeckoSession, prompt: GeckoSession.PromptDelegate.AlertPrompt): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
+ return null;
+ }
+ })
+ }
+
+ @Test fun waitForJS_resolvePromise() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ assertThat("waitForJS should wait for promises",
+ sessionRule.session.waitForJS("Promise.resolve('foo')") as String,
+ equalTo("foo"))
+ }
+
+ @Test fun waitForJS_delegateDuringWait() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var count = 0
+ sessionRule.session.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ override fun onAlertPrompt(session: GeckoSession, prompt: GeckoSession.PromptDelegate.AlertPrompt): GeckoResult<GeckoSession.PromptDelegate.PromptResponse> {
+ count++
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ sessionRule.session.waitForJS("alert()")
+ sessionRule.session.waitForJS("alert()")
+
+ // The delegate set through delegateDuringNextWait
+ // should have been cleared after the first wait.
+ assertThat("Delegate should only run once", count, equalTo(1))
+ }
+
+ 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() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ 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() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ 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()
+ }
+
+ @IgnoreCrash
+ @Test fun contentCrashIgnored() {
+ // TODO: Bug 1673953
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ mainSession.loadUri(CONTENT_CRASH_URL)
+ mainSession.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onCrash(session: GeckoSession) = Unit
+ })
+ }
+
+ @Test(expected = ChildCrashedException::class)
+ fun contentCrashFails() {
+ assumeThat(sessionRule.env.shouldShutdownOnCrash(), equalTo(false))
+
+ sessionRule.session.loadUri(CONTENT_CRASH_URL)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test fun waitForResult() {
+ val handler = Handler(Looper.getMainLooper())
+ val result = object : GeckoResult<Int>() {
+ init {
+ handler.postDelayed({
+ complete(42)
+ }, 100)
+ }
+ }
+
+ val value = sessionRule.waitForResult(result)
+ assertThat("Value should match", value, equalTo(42))
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun waitForResultExceptionally() {
+ val handler = Handler(Looper.getMainLooper())
+ val result = object : GeckoResult<Int>() {
+ init {
+ handler.postDelayed({
+ completeExceptionally(IllegalStateException("boom"))
+ }, 100)
+ }
+ }
+
+ sessionRule.waitForResult(result)
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt
new file mode 100644
index 0000000000..04f0e07f00
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt
@@ -0,0 +1,69 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.LargeTest
+import androidx.test.rule.ActivityTestRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.core.view.ViewCompat
+import android.view.View
+
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class GeckoViewTest {
+ val activityRule = ActivityTestRule<GeckoViewTestActivity>(GeckoViewTestActivity::class.java)
+ var sessionRule = GeckoSessionTestRule()
+
+ val view get() = activityRule.activity.view
+
+ @get:Rule
+ val rules = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ @Before
+ fun setup() {
+ // Attach the default session from the session rule to the GeckoView
+ view.setSession(sessionRule.session)
+ }
+
+ @After
+ fun cleanup() {
+ view.releaseSession()
+ }
+
+ @Test
+ fun setSessionOnClosed() {
+ view.session!!.close()
+ view.setSession(GeckoSession())
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun setSessionOnOpenThrows() {
+ assertThat("Session is open", view.session!!.isOpen, equalTo(true))
+ view.setSession(GeckoSession())
+ }
+
+ @Test(expected = java.lang.IllegalStateException::class)
+ fun displayAlreadyAcquired() {
+ assertThat("View should be attached",
+ ViewCompat.isAttachedToWindow(view), equalTo(true))
+ view.session!!.acquireDisplay()
+ }
+
+ @Test
+ fun relaseOnDetach() {
+ // The GeckoDisplay should be released when the View is detached from the window...
+ view.onDetachedFromWindow()
+ view.session!!.releaseDisplay(view.session!!.acquireDisplay())
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java
new file mode 100644
index 0000000000..9686145461
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java
@@ -0,0 +1,24 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test;
+
+import org.mozilla.geckoview.GeckoView;
+
+import android.app.Activity;
+import android.content.ContextWrapper;
+import android.os.Bundle;
+
+public class GeckoViewTestActivity extends Activity {
+ public GeckoView view;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ view = new GeckoView(new ContextWrapper(this));
+ setContentView(view);
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt
new file mode 100644
index 0000000000..28e3661f70
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt
@@ -0,0 +1,221 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.test.util.Callbacks
+import org.junit.Ignore
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class HistoryDelegateTest : BaseSessionTest() {
+ companion object {
+ // Keep in sync with the styles in `LINKS_HTML_PATH`.
+ const val UNVISITED_COLOR = "rgb(0, 0, 255)"
+ const val VISITED_COLOR = "rgb(255, 0, 0)"
+ }
+
+ @Test fun getVisited() {
+ val testUri = createTestUrl(LINKS_HTML_PATH)
+ sessionRule.delegateDuringNextWait(object : GeckoSession.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onVisited(session: GeckoSession, url: String,
+ lastVisitedURL: String?,
+ flags: Int): GeckoResult<Boolean>? {
+ assertThat("Should pass visited URL", url, equalTo(testUri))
+ assertThat("Should not pass last visited URL", lastVisitedURL, nullValue())
+ assertThat("Should set visit flags", flags,
+ equalTo(GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL))
+ return GeckoResult.fromValue(true)
+ }
+
+ @AssertCalled(count = 1)
+ override fun getVisited(session: GeckoSession,
+ urls: Array<String>) : GeckoResult<BooleanArray>? {
+ val expected = arrayOf(
+ "https://mozilla.org/",
+ "https://getfirefox.com/",
+ "https://bugzilla.mozilla.org/",
+ "https://testpilot.firefox.com/",
+ "https://accounts.firefox.com/"
+ )
+ assertThat("Should pass URLs to check", urls.sorted(),
+ equalTo(expected.sorted()))
+
+ val visits = BooleanArray(urls.size, {
+ when (urls[it]) {
+ "https://mozilla.org/", "https://testpilot.firefox.com/" -> true
+ else -> false
+ }
+ })
+ return GeckoResult.fromValue(visits)
+ }
+ })
+
+ // Since `getVisited` is called asynchronously after the page loads, we
+ // can't use `waitForPageStop` here.
+ sessionRule.session.loadUri(testUri)
+ sessionRule.session.waitUntilCalled(GeckoSession.HistoryDelegate::class,
+ "onVisited", "getVisited")
+
+ // Sometimes link changes are not applied immediately, wait for a little bit
+ UiThreadUtils.waitForCondition({
+ sessionRule.getLinkColor(testUri, "#mozilla") == VISITED_COLOR
+ }, sessionRule.env.defaultTimeoutMillis)
+
+ assertThat(
+ "Mozilla should be visited",
+ sessionRule.getLinkColor(testUri, "#mozilla"),
+ equalTo(VISITED_COLOR)
+ )
+
+ assertThat(
+ "Test Pilot should be visited",
+ sessionRule.getLinkColor(testUri, "#testpilot"),
+ equalTo(VISITED_COLOR)
+ )
+
+ assertThat(
+ "Bugzilla should be unvisited",
+ sessionRule.getLinkColor(testUri, "#bugzilla"),
+ equalTo(UNVISITED_COLOR)
+ )
+ }
+
+ @Ignore //disable test on debug for frequent failures Bug 1544169
+ @Test fun onHistoryStateChange() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have one entry", state.size,
+ equalTo(1))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO_HTML_PATH))
+ assertThat("History index should be 0", state.currentIndex,
+ equalTo(0))
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO2_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size,
+ equalTo(2))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO2_HTML_PATH))
+ assertThat("History index should be 1", state.currentIndex,
+ equalTo(1))
+ }
+ })
+
+ sessionRule.session.goBack()
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size,
+ equalTo(2))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO_HTML_PATH))
+ assertThat("History index should be 0", state.currentIndex,
+ equalTo(0))
+ }
+ })
+
+ sessionRule.session.goForward()
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size,
+ equalTo(2))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO2_HTML_PATH))
+ assertThat("History index should be 1", state.currentIndex,
+ equalTo(1))
+ }
+ })
+
+ sessionRule.session.gotoHistoryIndex(0)
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size,
+ equalTo(2))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO_HTML_PATH))
+ assertThat("History index should be 1", state.currentIndex,
+ equalTo(0))
+ }
+ })
+
+ sessionRule.session.gotoHistoryIndex(1)
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size,
+ equalTo(2))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO2_HTML_PATH))
+ assertThat("History index should be 1", state.currentIndex,
+ equalTo(1))
+ }
+ })
+ }
+
+ @Test fun onHistoryStateChangeSavingState() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ // This is a smaller version of the above test, in the hopes to minimize race conditions
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have one entry", state.size,
+ equalTo(1))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO_HTML_PATH))
+ assertThat("History index should be 0", state.currentIndex,
+ equalTo(0))
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO2_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : Callbacks.HistoryDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size,
+ equalTo(2))
+ assertThat("URLs should match", state[state.currentIndex].uri,
+ endsWith(HELLO2_HTML_PATH))
+ assertThat("History index should be 1", state.currentIndex,
+ equalTo(1))
+ }
+ })
+ }
+
+
+
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt
new file mode 100644
index 0000000000..1c203e8596
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt
@@ -0,0 +1,270 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Log
+
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.Assume.assumeThat
+import org.junit.Assume.assumeTrue
+
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.util.Callbacks
+
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.gecko.util.ImageResource
+
+class TestImage(
+ val path: String,
+ val type: String?,
+ val sizes: String?,
+ val widths: Array<Int>?,
+ val heights: Array<Int>?) {}
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ImageResourceTest : BaseSessionTest() {
+ companion object {
+ val kValidTestImage1 = TestImage(
+ "path.ico", "image/icon", "16x16 32x32 64x64",
+ arrayOf(16, 32, 64),
+ arrayOf(16, 32, 64)
+ )
+
+ val kValidTestImage2 = TestImage(
+ "path.png", "image/png", "128x128",
+ arrayOf(128),
+ arrayOf(128)
+ )
+
+ val kValidTestImage3 = TestImage(
+ "path.jpg", "image/jpg", "256x256",
+ arrayOf(256),
+ arrayOf(256)
+ )
+
+ val kValidTestImage4 = TestImage(
+ "path.png", "image/png", "300x128",
+ arrayOf(300),
+ arrayOf(128)
+ )
+
+ val kValidTestImage5 = TestImage(
+ "path.svg", "image/svg", "any",
+ arrayOf(0),
+ arrayOf(0)
+ )
+
+ val kValidTestImage6 = TestImage(
+ "path.svg", null, null,
+ null,
+ null
+ )
+ }
+
+ fun verifyEqual(image: ImageResource, base: TestImage) {
+ assertThat(
+ "Path should match",
+ image.src,
+ equalTo(base.path))
+ assertThat(
+ "Type should match",
+ image.type,
+ equalTo(base.type))
+
+ assertThat(
+ "Sizes should match",
+ image.sizes?.size,
+ equalTo(base.widths?.size))
+
+ assertThat(
+ "Sizes should match",
+ image.sizes?.size,
+ equalTo(base.heights?.size))
+
+ if (image.sizes == null) {
+ return;
+ }
+ for (i in 0 until image.sizes!!.size) {
+ assertThat(
+ "Sizes widths should match",
+ image.sizes!![i].width,
+ equalTo(base.widths!![i]))
+ assertThat(
+ "Sizes heights should match",
+ image.sizes!![i].height,
+ equalTo(base.heights!![i]))
+ }
+ }
+
+ fun testValidImage(base: TestImage) {
+ var image = ImageResource(base.path, base.type, base.sizes)
+ verifyEqual(image, base)
+ }
+
+ fun buildCollection(bases: Array<TestImage>) : ImageResource.Collection {
+ val builder = ImageResource.Collection.Builder()
+
+ bases.forEach {
+ builder.add(ImageResource(it.path, it.type, it.sizes))
+ }
+
+ return builder.build()
+ }
+
+ @Test
+ fun validImage() {
+ testValidImage(kValidTestImage1)
+ testValidImage(kValidTestImage2)
+ testValidImage(kValidTestImage3)
+ testValidImage(kValidTestImage4)
+ testValidImage(kValidTestImage5)
+ testValidImage(kValidTestImage6)
+ }
+
+ @Test
+ fun invalidImageSize() {
+ val invalidImage1 = TestImage(
+ "path.ico", "image/icon", "16x16 32",
+ arrayOf(16),
+ arrayOf(16)
+ )
+ testValidImage(invalidImage1)
+
+ val invalidImage2 = TestImage(
+ "path.ico", "image/icon", "16x16 32xa32",
+ arrayOf(16),
+ arrayOf(16)
+ )
+ testValidImage(invalidImage2)
+
+ val invalidImage3 = TestImage(
+ "path.ico", "image/icon", "",
+ null,
+ null
+ )
+ testValidImage(invalidImage3)
+
+ val invalidImage4 = TestImage(
+ "path.ico", "image/icon", "abxab",
+ null,
+ null
+ )
+ testValidImage(invalidImage4)
+ }
+
+ @Test
+ fun getBestRegular() {
+ val collection = buildCollection(arrayOf(
+ kValidTestImage1, kValidTestImage2, kValidTestImage3,
+ kValidTestImage4))
+ // 16, 32, 64
+ verifyEqual(collection.getBest(10)!!, kValidTestImage1)
+ verifyEqual(collection.getBest(16)!!, kValidTestImage1)
+ verifyEqual(collection.getBest(30)!!, kValidTestImage1)
+ verifyEqual(collection.getBest(90)!!, kValidTestImage1)
+
+ // 128
+ verifyEqual(collection.getBest(100)!!, kValidTestImage2)
+ verifyEqual(collection.getBest(120)!!, kValidTestImage2)
+ verifyEqual(collection.getBest(140)!!, kValidTestImage2)
+
+ // 256
+ verifyEqual(collection.getBest(210)!!, kValidTestImage3)
+ verifyEqual(collection.getBest(256)!!, kValidTestImage3)
+ verifyEqual(collection.getBest(270)!!, kValidTestImage3)
+
+ // 300
+ verifyEqual(collection.getBest(280)!!, kValidTestImage4)
+ verifyEqual(collection.getBest(10000)!!, kValidTestImage4)
+ }
+
+ @Test
+ fun getBestAny() {
+ val collection = buildCollection(arrayOf(
+ kValidTestImage1, kValidTestImage2, kValidTestImage3,
+ kValidTestImage4, kValidTestImage5))
+ // any
+ verifyEqual(collection.getBest(10)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(16)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(30)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(90)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(100)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(120)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(140)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(210)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(256)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(270)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(280)!!, kValidTestImage5)
+ verifyEqual(collection.getBest(10000)!!, kValidTestImage5)
+ }
+
+ @Test
+ fun getBestNull() {
+ // Don't include `any` since two `any` cases would result in undefined
+ // results.
+ val collection = buildCollection(arrayOf(
+ kValidTestImage1, kValidTestImage2, kValidTestImage3,
+ kValidTestImage4, kValidTestImage6))
+ // null, handled as any
+ verifyEqual(collection.getBest(10)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(16)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(30)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(90)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(100)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(120)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(140)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(210)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(256)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(270)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(280)!!, kValidTestImage6)
+ verifyEqual(collection.getBest(10000)!!, kValidTestImage6)
+ }
+
+ @Test
+ fun getBitmap() {
+ val actualWidth = 265
+ val actualHeight = 199
+
+ val testImage = TestImage(
+ createTestUrl("/assets/www/images/test.gif"),
+ "image/gif",
+ "any",
+ arrayOf(0),
+ arrayOf(0)
+ )
+ val collection = buildCollection(arrayOf(testImage))
+ val image = collection.getBest(actualWidth)
+
+ verifyEqual(image!!, testImage)
+
+ sessionRule.waitForResult(image.getBitmap(actualWidth)
+ .then { bitmap ->
+ assertThat(
+ "Bitmap should be non-null",
+ bitmap,
+ notNullValue())
+ assertThat(
+ "Bitmap width should match",
+ bitmap!!.getWidth(),
+ equalTo(actualWidth))
+ assertThat(
+ "Bitmap height should match",
+ bitmap.getHeight(),
+ equalTo(actualHeight))
+
+ GeckoResult.fromValue(null)
+ })
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt
new file mode 100644
index 0000000000..4780061eee
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt
@@ -0,0 +1,24 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoSession
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LocaleTest : BaseSessionTest() {
+
+ @Test fun setLocale() {
+ sessionRule.runtime.settings.setLocales(arrayOf("en-GB"));
+ assertThat("Requested locale is found", sessionRule.requestedLocales.indexOf("en-GB"),
+ greaterThanOrEqualTo(0))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt
new file mode 100644
index 0000000000..f2e13be918
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt
@@ -0,0 +1,159 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Log
+import org.hamcrest.Matchers
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.Assume.assumeThat
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class MediaDelegateTest : BaseSessionTest() {
+
+ private fun requestRecordingPermission(allowAudio: Boolean, allowCamera: Boolean) {
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onMediaPermissionRequest(
+ session: GeckoSession, uri: String,
+ video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ callback: GeckoSession.PermissionDelegate.MediaCallback) {
+ if (! (allowAudio || allowCamera)) {
+ callback.reject();
+ return;
+ }
+ var audioDevice: GeckoSession.PermissionDelegate.MediaSource? = null;
+ var videoDevice: GeckoSession.PermissionDelegate.MediaSource? = null;
+ if (allowAudio) {
+ audioDevice = audio!![0];
+ }
+ if (allowCamera) {
+ videoDevice = video!![0];
+ }
+
+ if (videoDevice != null || audioDevice != null) {
+ callback.grant(videoDevice, audioDevice);
+ }
+ }
+
+ override fun onAndroidPermissionsRequest(session: GeckoSession,
+ permissions: Array<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ callback.grant()
+ }
+ })
+
+ mainSession.delegateDuringNextWait(object : Callbacks.MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onRecordingStatusChanged(session: GeckoSession,
+ devices: Array<RecordingDevice>) {
+ var audioActive = false
+ var cameraActive = false
+ for (device in devices) {
+ if (device.type == RecordingDevice.Type.MICROPHONE) {
+ audioActive = device.status != RecordingDevice.Status.INACTIVE
+ }
+ if (device.type == RecordingDevice.Type.CAMERA) {
+ cameraActive = device.status != RecordingDevice.Status.INACTIVE
+ }
+ }
+
+ assertThat("Camera is ${if (allowCamera) { "active" } else { "inactive" }}",
+ cameraActive, Matchers.equalTo(allowCamera))
+
+ assertThat("Audio is ${if (allowAudio ) { "active" } else { "inactive" }}" ,
+ audioActive, Matchers.equalTo(allowAudio))
+
+ }
+ })
+
+ var code: String?
+ if (allowAudio && allowCamera) {
+ code = """this.stream = window.navigator.mediaDevices.getUserMedia({
+ video: { width: 320, height: 240, frameRate: 10 },
+ audio: true
+ });"""
+ } else if (allowAudio) {
+ code = """this.stream = window.navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });"""
+ } else if (allowCamera) {
+ code = """this.stream = window.navigator.mediaDevices.getUserMedia({
+ video: { width: 320, height: 240, frameRate: 10 },
+ });"""
+ } else {
+ return
+ }
+
+ // Stop the stream and check active flag and id
+ val isActive = mainSession.waitForJS(
+ """$code
+ this.stream.then(stream => {
+ if (!stream.active || stream.id == '') {
+ return false;
+ }
+
+ return true;
+ })
+ """.trimMargin()) as Boolean
+
+ assertThat("Stream should be active and id should not be empty.", isActive,
+ Matchers.equalTo(true))
+ }
+
+ @Test fun testDeviceRecordingEventAudio() {
+ // disable test on debug Bug 1555656
+ assumeThat(sessionRule.env.isDebugBuild, Matchers.equalTo(false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val devices = mainSession.waitForJS(
+ "window.navigator.mediaDevices.enumerateDevices()").asJSList<JSONObject>()
+ val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" }
+ if (audioDevice != null) {
+ requestRecordingPermission(allowAudio = true, allowCamera = false);
+ }
+ }
+
+ @Test fun testDeviceRecordingEventVideo() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val devices = mainSession.waitForJS(
+ "window.navigator.mediaDevices.enumerateDevices()").asJSList<JSONObject>()
+
+ val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" }
+ if (videoDevice != null) {
+ requestRecordingPermission(allowAudio = false, allowCamera = true)
+ }
+
+ }
+
+ @Test fun testDeviceRecordingEventAudioAndVideo() {
+ // disabled test on debug builds Bug 1554189
+ assumeThat(sessionRule.env.isDebugBuild, Matchers.equalTo(false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val devices = mainSession.waitForJS(
+ "window.navigator.mediaDevices.enumerateDevices()").asJSList<JSONObject>()
+ val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" }
+ val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" }
+ if (audioDevice != null && videoDevice != null) {
+ requestRecordingPermission(allowAudio = true, allowCamera = true);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt
new file mode 100644
index 0000000000..e179161b16
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt
@@ -0,0 +1,180 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Log
+import org.hamcrest.Matchers
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.Assume.assumeThat
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class MediaDelegateXOriginTest : BaseSessionTest() {
+
+ private fun requestRecordingPermission(allowAudio: Boolean, allowCamera: Boolean) {
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onMediaPermissionRequest(
+ session: GeckoSession, uri: String,
+ video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ callback: GeckoSession.PermissionDelegate.MediaCallback) {
+ if (! (allowAudio || allowCamera)) {
+ callback.reject();
+ return;
+ }
+ var audioDevice: GeckoSession.PermissionDelegate.MediaSource? = null;
+ var videoDevice: GeckoSession.PermissionDelegate.MediaSource? = null;
+ if (allowAudio) {
+ audioDevice = audio!![0];
+ }
+ if (allowCamera) {
+ videoDevice = video!![0];
+ }
+
+ if (videoDevice != null || audioDevice != null) {
+ callback.grant(videoDevice, audioDevice);
+ }
+ }
+
+ override fun onAndroidPermissionsRequest(session: GeckoSession,
+ permissions: Array<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ callback.grant()
+ }
+ })
+
+ mainSession.delegateDuringNextWait(object : Callbacks.MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onRecordingStatusChanged(session: GeckoSession,
+ devices: Array<RecordingDevice>) {
+ var audioActive = false
+ var cameraActive = false
+ for (device in devices) {
+ if (device.type == RecordingDevice.Type.MICROPHONE) {
+ audioActive = device.status != RecordingDevice.Status.INACTIVE
+ }
+ if (device.type == RecordingDevice.Type.CAMERA) {
+ cameraActive = device.status != RecordingDevice.Status.INACTIVE
+ }
+ }
+
+ assertThat("Camera is ${if (allowCamera) { "active" } else { "inactive" }}",
+ cameraActive, Matchers.equalTo(allowCamera))
+
+ assertThat("Audio is ${if (allowAudio ) { "active" } else { "inactive" }}" ,
+ audioActive, Matchers.equalTo(allowAudio))
+
+ }
+ })
+
+ var constraints : String?
+ if (allowAudio && allowCamera) {
+ constraints = """{
+ video: { width: 320, height: 240, frameRate: 10 },
+ audio: true
+ }"""
+ } else if (allowAudio) {
+ constraints = "{ audio: true }"
+ } else if (allowCamera) {
+ constraints = "{video: { width: 320, height: 240, frameRate: 10 }}"
+ } else {
+ return
+ }
+
+ val started = mainSession.waitForJS("Start($constraints)") as String
+ assertThat("getUserMedia should have succeeded", started, Matchers.equalTo("ok"))
+
+ val stopped = mainSession.waitForJS("Stop()") as Boolean
+ assertThat("stream should have been stopped", stopped, Matchers.equalTo(true))
+ }
+
+ private fun requestRecordingPermissionNoAllow(allowAudio: Boolean, allowCamera: Boolean) {
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 0)
+ override fun onMediaPermissionRequest(
+ session: GeckoSession, uri: String,
+ video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ callback: GeckoSession.PermissionDelegate.MediaCallback) {
+ callback.reject()
+ }
+
+ @GeckoSessionTestRule.AssertCalled(count = 0)
+ override fun onAndroidPermissionsRequest(session: GeckoSession,
+ permissions: Array<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ callback.reject()
+ }
+ })
+
+ mainSession.delegateDuringNextWait(object : Callbacks.MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 0)
+ override fun onRecordingStatusChanged(session: GeckoSession,
+ devices: Array<RecordingDevice>) {}
+ })
+
+ var constraints : String?
+ if (allowAudio && allowCamera) {
+ constraints = """{
+ video: { width: 320, height: 240, frameRate: 10 },
+ audio: true
+ }"""
+ } else if (allowAudio) {
+ constraints = "{ audio: true }"
+ } else if (allowCamera) {
+ constraints = "{video: { width: 320, height: 240, frameRate: 10 }}"
+ } else {
+ return
+ }
+
+ val started = mainSession.waitForJS("StartNoAllow($constraints)") as String
+ assertThat("getUserMedia should not be allowed", started, Matchers.startsWith("NotAllowedError"))
+
+ val stopped = mainSession.waitForJS("Stop()") as Boolean
+ assertThat("stream stop should fail", stopped, Matchers.equalTo(false))
+ }
+
+ @Test fun testDeviceRecordingEventAudioAndVideoInXOriginIframe() {
+ // TODO: Bug 1648153
+ assumeThat(sessionRule.env.isFission, Matchers.equalTo(false))
+
+ mainSession.loadTestPath(GETUSERMEDIA_XORIGIN_CONTAINER_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val devices = mainSession.waitForJS(
+ "window.navigator.mediaDevices.enumerateDevices()").asJSList<JSONObject>()
+ val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" }
+ val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" }
+ requestRecordingPermission(allowAudio = audioDevice != null,
+ allowCamera = videoDevice != null)
+ }
+
+ @Test fun testDeviceRecordingEventAudioAndVideoInXOriginIframeNoAllow() {
+ // TODO: Bug 1648153
+ assumeThat(sessionRule.env.isFission, Matchers.equalTo(false))
+
+ mainSession.loadTestPath(GETUSERMEDIA_XORIGIN_CONTAINER_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val devices = mainSession.waitForJS(
+ "window.navigator.mediaDevices.enumerateDevices()").asJSList<JSONObject>()
+ val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" }
+ val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" }
+ requestRecordingPermissionNoAllow(allowAudio = audioDevice != null,
+ allowCamera = videoDevice != null)
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaElementTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaElementTest.kt
new file mode 100644
index 0000000000..db8d900610
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaElementTest.kt
@@ -0,0 +1,414 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.MediaElement
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.TimeoutMillis
+import org.mozilla.geckoview.test.util.Callbacks
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Assume.assumeThat
+import org.junit.Assume.assumeTrue
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoRuntimeSettings
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.TEST_ENDPOINT
+
+@RunWith(AndroidJUnit4::class)
+@TimeoutMillis(45000)
+@MediumTest
+class MediaElementTest : BaseSessionTest() {
+
+ interface MediaElementDelegate : MediaElement.Delegate {
+ override fun onPlaybackStateChange(mediaElement: MediaElement, mediaState: Int) {}
+ override fun onReadyStateChange(mediaElement: MediaElement, readyState: Int) {}
+ override fun onMetadataChange(mediaElement: MediaElement, metaData: MediaElement.Metadata) {}
+ override fun onLoadProgress(mediaElement: MediaElement, progressInfo: MediaElement.LoadProgressInfo) {}
+ override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) {}
+ override fun onTimeChange(mediaElement: MediaElement, time: Double) {}
+ override fun onPlaybackRateChange(mediaElement: MediaElement, rate: Double) {}
+ override fun onFullscreenChange(mediaElement: MediaElement, fullscreen: Boolean) {}
+ override fun onError(mediaElement: MediaElement, errorCode: Int) {}
+ }
+
+ private fun setupPrefs() {
+
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "media.autoplay.default" to 0,
+ "full-screen-api.allow-trusted-requests-only" to false))
+
+ }
+
+ private fun setupDelegate(path: String) {
+ sessionRule.session.loadTestPath(path)
+ sessionRule.waitUntilCalled(object : Callbacks.MediaDelegate {
+ @AssertCalled
+ override fun onMediaAdd(session: GeckoSession, element: MediaElement) {
+ sessionRule.addExternalDelegateUntilTestEnd(
+ MediaElementDelegate::class,
+ element::setDelegate,
+ { element.delegate = null },
+ object : MediaElementDelegate {})
+ }
+ })
+ }
+
+ private fun setupPrefsAndDelegates(path: String) {
+ setupPrefs()
+ setupDelegate(path)
+ }
+
+ private fun waitUntilState(waitState: Int = MediaElement.MEDIA_READY_STATE_HAVE_ENOUGH_DATA): MediaElement {
+ var ready = false
+ var result: MediaElement? = null
+ while (!ready) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onReadyStateChange(mediaElement: MediaElement, readyState: Int) {
+ if (readyState == waitState) {
+ ready = true
+ result = mediaElement
+ }
+ }
+ })
+ }
+ if (result == null) {
+ throw IllegalStateException("No MediaElement Found")
+ }
+ return result!!
+ }
+
+ private fun waitUntilVideoReady(path: String, waitState: Int = MediaElement.MEDIA_READY_STATE_HAVE_ENOUGH_DATA): MediaElement {
+ setupPrefsAndDelegates(path)
+ return waitUntilState(waitState)
+ }
+
+ private fun waitUntilVideoReadyNoPrefs(path: String, waitState: Int = MediaElement.MEDIA_READY_STATE_HAVE_ENOUGH_DATA): MediaElement {
+ setupDelegate(path)
+ return waitUntilState(waitState)
+ }
+
+ private fun waitForPlaybackStateChange(waitState: Int, lambda: (element: MediaElement, state: Int) -> Unit = { _: MediaElement, _: Int -> }) {
+ var waiting = true
+ while (waiting) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onPlaybackStateChange(mediaElement: MediaElement, mediaState: Int) {
+ if (mediaState == waitState) {
+ waiting = false
+ lambda(mediaElement, mediaState)
+ }
+ }
+ })
+ }
+ }
+
+ private fun waitForMetadata(path: String): MediaElement.Metadata? {
+ setupPrefsAndDelegates(path)
+ var meta: MediaElement.Metadata? = null
+ while (meta == null) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onMetadataChange(mediaElement: MediaElement, metaData: MediaElement.Metadata) {
+ meta = metaData
+ }
+ })
+ }
+ return meta
+ }
+
+ private fun playMedia(path: String) {
+ val mediaElement = waitUntilVideoReady(path)
+ mediaElement.play()
+ waitForPlaybackStateChange(MediaElement.MEDIA_STATE_PLAY)
+ waitForPlaybackStateChange(MediaElement.MEDIA_STATE_PLAYING)
+ }
+
+ private fun playMediaFromScript(path: String) {
+ waitUntilVideoReady(path)
+ mainSession.evaluateJS("document.querySelector('video').play()")
+ waitForPlaybackStateChange(MediaElement.MEDIA_STATE_PLAY)
+ waitForPlaybackStateChange(MediaElement.MEDIA_STATE_PLAYING)
+ }
+
+ private fun pauseMedia(path: String) {
+ val mediaElement = waitUntilVideoReady(path)
+ mediaElement.play()
+ waitForPlaybackStateChange(MediaElement.MEDIA_STATE_PLAYING) { element: MediaElement, _: Int ->
+ element.pause()
+ }
+ waitForPlaybackStateChange(MediaElement.MEDIA_STATE_PAUSE)
+ }
+
+ private fun timeMedia(path: String, limit: Double) {
+ val mediaElement = waitUntilVideoReady(path)
+ mediaElement.play()
+ var waiting = true
+ while (waiting) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onTimeChange(mediaElement: MediaElement, time: Double) {
+ if (time > limit) {
+ waiting = false
+ }
+ }
+ })
+ }
+ }
+
+ private fun seekMedia(path: String, seek: Double) {
+ val media = waitUntilVideoReady(path)
+ media.seek(seek)
+ var waiting = true
+ // Sometimes we get a MediaElement.MEDIA_STATE_SUSPEND state change. So just wait until
+ // the test receives the SEEKING state change or time out.
+ while (waiting) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onPlaybackStateChange(mediaElement: MediaElement, mediaState: Int) {
+ if (mediaState == MediaElement.MEDIA_STATE_SEEKING) {
+ waiting = false
+ }
+ }
+ })
+ }
+ waiting = true
+ while (waiting) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onTimeChange(mediaElement: MediaElement, time: Double) {
+ if (time >= seek) {
+ waiting = false
+ }
+ }
+ })
+ }
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onPlaybackStateChange(mediaElement: MediaElement, mediaState: Int) {
+ assertThat("Done seeking", mediaState, equalTo(MediaElement.MEDIA_STATE_SEEKED))
+ }
+ })
+ }
+
+ private fun fullscreenMedia(path: String) {
+ waitUntilVideoReady(path)
+ mainSession.evaluateJS("document.querySelector('video').requestFullscreen()")
+ var waiting = true
+ while (waiting) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onFullscreenChange(mediaElement: MediaElement, fullscreen: Boolean) {
+ if (fullscreen) {
+ waiting = false
+ }
+ }
+ })
+ }
+ }
+
+ @Test
+ fun oggPlayMedia() {
+ playMedia(VIDEO_OGG_PATH)
+ }
+
+ @Ignore //disable test for frequent failures Bug 1554117
+ @Test
+ fun oggPlayMediaFromScript() {
+ playMediaFromScript(VIDEO_OGG_PATH)
+ }
+
+ @Test
+ fun oggPauseMedia() {
+ pauseMedia(VIDEO_OGG_PATH)
+ }
+
+ @Test
+ fun oggTimeMedia() {
+ timeMedia(VIDEO_OGG_PATH, 0.2)
+ }
+
+ @Test
+ fun oggMetadataMedia() {
+ val meta = waitForMetadata(VIDEO_OGG_PATH)
+ assertThat("Current source is set", meta?.currentSource,
+ equalTo("$TEST_ENDPOINT/assets/www/videos/video.ogg"))
+ assertThat("Width is set", meta?.width, equalTo(320L))
+ assertThat("Height is set", meta?.height, equalTo(240L))
+ assertThat("Video is seekable", meta?.isSeekable, equalTo(true))
+ // Disabled duration test for Bug 1510393
+ // assertThat("Duration is set", meta?.duration, closeTo(4.0, 0.1))
+ assertThat("Contains one video track", meta?.videoTrackCount, equalTo(1))
+ assertThat("Contains one audio track", meta?.audioTrackCount, equalTo(0))
+ }
+
+ @Test
+ fun oggSeekMedia() {
+ seekMedia(VIDEO_OGG_PATH, 2.0)
+ }
+
+ @Test
+ fun oggFullscreenMedia() {
+ fullscreenMedia(VIDEO_OGG_PATH)
+ }
+
+ @Test
+ fun webmPlayMedia() {
+ playMedia(VIDEO_WEBM_PATH)
+ }
+
+ @Test
+ fun webmPlayMediaFromScript() {
+ // disable test on pgo and debug for frequently failing Bug 1532404
+ assumeTrue(false)
+ playMediaFromScript(VIDEO_WEBM_PATH)
+ }
+
+ @Test
+ fun webmPauseMedia() {
+ pauseMedia(VIDEO_WEBM_PATH)
+ }
+
+ @Test
+ fun webmTimeMedia() {
+ timeMedia(VIDEO_WEBM_PATH, 0.2)
+ }
+
+ @Test
+ fun webmMetadataMedia() {
+ val meta = waitForMetadata(VIDEO_WEBM_PATH)
+ assertThat("Current source is set", meta?.currentSource,
+ equalTo("$TEST_ENDPOINT/assets/www/videos/gizmo.webm"))
+ assertThat("Width is set", meta?.width, equalTo(560L))
+ assertThat("Height is set", meta?.height, equalTo(320L))
+ assertThat("Video is seekable", meta?.isSeekable, equalTo(true))
+ assertThat("Duration is set", meta?.duration, closeTo(5.6, 0.1))
+ assertThat("Contains one video track", meta?.videoTrackCount, equalTo(1))
+ assertThat("Contains one audio track", meta?.audioTrackCount, equalTo(1))
+ }
+
+ @Test
+ fun webmSeekMedia() {
+ seekMedia(VIDEO_WEBM_PATH, 0.2)
+ }
+
+ @Test
+ fun webmFullscreenMedia() {
+ fullscreenMedia(VIDEO_WEBM_PATH)
+ }
+
+ private fun waitForVolumeChange(volumeLevel: Double, isMuted: Boolean) {
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) {
+ assertThat("Volume was set", volume, closeTo(volumeLevel, 0.0001))
+ assertThat("Not muted", muted, equalTo(isMuted))
+ }
+ })
+ }
+
+ @Test
+ fun webmVolumeMedia() {
+ val media = waitUntilVideoReady(VIDEO_WEBM_PATH)
+ val volumeLevel = 0.5
+ val volumeLevel2 = 0.75
+ media.setVolume(volumeLevel)
+ waitForVolumeChange(volumeLevel, false)
+ media.setMuted(true)
+ waitForVolumeChange(volumeLevel, true)
+ media.setVolume(volumeLevel2)
+ waitForVolumeChange(volumeLevel2, true)
+ media.setMuted(false)
+ waitForVolumeChange(volumeLevel2, false)
+ }
+
+ // NOTE: All MP4 tests are disabled on automation by Bug 1503952
+ @Test
+ fun mp4PlayMedia() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ playMedia(VIDEO_MP4_PATH)
+ }
+
+ @Test
+ fun mp4PlayMediaFromScript() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ playMediaFromScript(VIDEO_MP4_PATH)
+ }
+
+ @Test
+ fun mp4PauseMedia() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ pauseMedia(VIDEO_MP4_PATH)
+ }
+
+ @Test
+ fun mp4TimeMedia() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ timeMedia(VIDEO_MP4_PATH, 0.2)
+ }
+
+ @Test
+ fun mp4MetadataMedia() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ val meta = waitForMetadata(VIDEO_MP4_PATH)
+ assertThat("Current source is set", meta?.currentSource,
+ equalTo("$TEST_ENDPOINT/assets/www/videos/short.mp4"))
+ assertThat("Width is set", meta?.width, equalTo(320L))
+ assertThat("Height is set", meta?.height, equalTo(240L))
+ assertThat("Video is seekable", meta?.isSeekable, equalTo(true))
+ assertThat("Duration is set", meta?.duration, closeTo(0.5, 0.1))
+ assertThat("Contains one video track", meta?.videoTrackCount, equalTo(1))
+ assertThat("Contains one audio track", meta?.audioTrackCount, equalTo(1))
+ }
+
+ @Test
+ fun mp4SeekMedia() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ seekMedia(VIDEO_MP4_PATH, 0.2)
+ }
+
+ @Test
+ fun mp4FullscreenMedia() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ fullscreenMedia(VIDEO_MP4_PATH)
+ }
+
+ @Test
+ fun mp4VolumeMedia() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ val media = waitUntilVideoReady(VIDEO_MP4_PATH)
+ val volumeLevel = 0.5
+ val volumeLevel2 = 0.75
+ media.setVolume(volumeLevel)
+ waitForVolumeChange(volumeLevel, false)
+ media.setMuted(true)
+ waitForVolumeChange(volumeLevel, true)
+ media.setVolume(volumeLevel2)
+ waitForVolumeChange(volumeLevel2, true)
+ media.setMuted(false)
+ waitForVolumeChange(volumeLevel2, false)
+ }
+
+ @Ignore
+ @Test
+ fun badMediaPath() {
+ // Disabled on automation by Bug 1503957
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ setupPrefsAndDelegates(VIDEO_BAD_PATH)
+ sessionRule.waitForPageStop()
+ sessionRule.waitUntilCalled(object : MediaElementDelegate {
+ @AssertCalled
+ override fun onError(mediaElement: MediaElement, errorCode: Int) {
+ assertThat("Got media error", errorCode, equalTo(MediaElement.MEDIA_ERROR_NETWORK_NO_SOURCE))
+ }
+ })
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt
new file mode 100644
index 0000000000..9abf6689b1
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt
@@ -0,0 +1,813 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Log
+
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.Assume.assumeThat
+import org.junit.Assume.assumeTrue
+
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.util.Callbacks
+
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.MediaSession
+
+class Metadata(
+ title: String?,
+ artist: String?,
+ album: String?)
+ : MediaSession.Metadata(title, artist, album, null) {}
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class MediaSessionTest : BaseSessionTest() {
+ companion object {
+ // See MEDIA_SESSION_DOM1_PATH file for details.
+ const val DOM_TEST_TITLE1 = "hoot"
+ const val DOM_TEST_TITLE2 = "hoot2"
+ const val DOM_TEST_TITLE3 = "hoot3"
+ const val DOM_TEST_ARTIST1 = "owl"
+ const val DOM_TEST_ARTIST2 = "stillowl"
+ const val DOM_TEST_ARTIST3 = "immaowl"
+ const val DOM_TEST_ALBUM1 = "hoots"
+ const val DOM_TEST_ALBUM2 = "dahoots"
+ const val DOM_TEST_ALBUM3 = "mahoots"
+ const val DEFAULT_TEST_TITLE1 = "MediaSessionDefaultTest1"
+ const val TEST_DURATION1 = 3.37
+ const val WEBM_TEST_DURATION = 5.59
+ const val WEBM_TEST_WIDTH = 560L
+ const val WEBM_TEST_HEIGHT = 320L
+
+ val DOM_META = arrayOf(
+ Metadata(
+ DOM_TEST_TITLE1,
+ DOM_TEST_ARTIST1,
+ DOM_TEST_ALBUM1),
+ Metadata(
+ DOM_TEST_TITLE2,
+ DOM_TEST_ARTIST2,
+ DOM_TEST_ALBUM2),
+ Metadata(
+ DOM_TEST_TITLE3,
+ DOM_TEST_ARTIST3,
+ DOM_TEST_ALBUM3))
+ }
+
+ @Before
+ fun setup() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "media.mediacontrol.stopcontrol.aftermediaends" to false,
+ "dom.media.mediasession.enabled" to true))
+ }
+
+ @After
+ fun teardown() {
+ }
+
+ @Test
+ fun domMetadataPlayback() {
+ val onActivatedCalled = arrayOf(GeckoResult<Void>())
+ val onMetadataCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>())
+ val onPlayCalled = arrayOf(GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>())
+ val onPauseCalled = arrayOf(GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>())
+
+ // Test:
+ // 1. Load DOM Media Session page which contains 3 audio tracks.
+ // 2. Track 1 is played on page load.
+ // a. Ensure onActivated is called.
+ // b. Ensure onMetadata (1) is called.
+ // c. Ensure onPlay (1) is called.
+ val completedStep2 = GeckoResult.allOf(
+ onActivatedCalled[0],
+ onMetadataCalled[0],
+ onPlayCalled[0])
+
+ // 3. Pause playback of track 1.
+ // a. Ensure onPause (1) is called.
+ val completedStep3 = GeckoResult.allOf(
+ onPauseCalled[0])
+
+ // 4. Resume playback (1).
+ // a. Ensure onMetadata (1) is called.
+ // b. Ensure onPlay (1) is called.
+ val completedStep4 = GeckoResult.allOf(
+ onPlayCalled[1],
+ onMetadataCalled[1])
+
+ // 5. Wait for track 1 end.
+ // a. Ensure onPause (1) is called.
+ val completedStep5 = GeckoResult.allOf(
+ onPauseCalled[1])
+
+ // 6. Play next track (2).
+ // a. Ensure onMetadata (2) is called.
+ // b. Ensure onPlay (2) is called.
+ val completedStep6 = GeckoResult.allOf(
+ onMetadataCalled[2],
+ onPlayCalled[2])
+
+ // 7. Play next track (3).
+ // a. Ensure onPause (2) is called.
+ // b. Ensure onMetadata (3) is called.
+ // c. Ensure onPlay (3) is called.
+ val completedStep7 = GeckoResult.allOf(
+ onPauseCalled[2],
+ onMetadataCalled[3],
+ onPlayCalled[3])
+
+ // 8. Play previous track (2).
+ // a. Ensure onPause (3) is called.
+ // b. Ensure onMetadata (2) is called.
+ // c. Ensure onPlay (2) is called.
+ val completedStep8a = GeckoResult.allOf(
+ onPauseCalled[3])
+ // Without the split, this seems to race and we don't get the pause event.
+ val completedStep8b = GeckoResult.allOf(
+ onMetadataCalled[4],
+ onPlayCalled[4])
+
+ // 9. Wait for track 2 end.
+ // a. Ensure onPause (2) is called.
+ val completedStep9 = GeckoResult.allOf(
+ onPauseCalled[4])
+
+ val path = MEDIA_SESSION_DOM1_PATH
+ val session1 = sessionRule.createOpenSession()
+
+ var mediaSession1 : MediaSession? = null
+ // 1.
+ session1.loadTestPath(path)
+
+ session1.delegateUntilTestEnd(object : Callbacks.MediaSessionDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onActivated(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onActivatedCalled[0].complete(null)
+ mediaSession1 = mediaSession
+ }
+
+ @AssertCalled(false)
+ override fun onDeactivated(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ }
+
+ @AssertCalled
+ override fun onFeatures(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ features: Long) {
+
+ val play = (features and MediaSession.Feature.PLAY) != 0L
+ val pause = (features and MediaSession.Feature.PAUSE) != 0L
+ val stop = (features and MediaSession.Feature.PAUSE) != 0L
+ val next = (features and MediaSession.Feature.PAUSE) != 0L
+ val prev = (features and MediaSession.Feature.PAUSE) != 0L
+
+ assertThat(
+ "Playback constrols should be supported",
+ play && pause && stop && next && prev,
+ equalTo(true))
+ }
+
+ @AssertCalled(count = 5, order = [2])
+ override fun onMetadata(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ meta: MediaSession.Metadata) {
+
+ assertThat(
+ "Title should match",
+ meta.title,
+ equalTo(forEachCall(
+ DOM_META[0].title,
+ DOM_META[0].title,
+ DOM_META[1].title,
+ DOM_META[2].title,
+ DOM_META[1].title)))
+ assertThat(
+ "Artist should match",
+ meta.artist,
+ equalTo(forEachCall(
+ DOM_META[0].artist,
+ DOM_META[0].artist,
+ DOM_META[1].artist,
+ DOM_META[2].artist,
+ DOM_META[1].artist)))
+ assertThat(
+ "Album should match",
+ meta.album,
+ equalTo(forEachCall(
+ DOM_META[0].album,
+ DOM_META[0].album,
+ DOM_META[1].album,
+ DOM_META[2].album,
+ DOM_META[1].album)))
+ assertThat(
+ "Artwork image should be non-null",
+ meta.artwork!!.getBitmap(200),
+ notNullValue())
+
+ onMetadataCalled[sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+
+ @AssertCalled
+ override fun onPositionState(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ state: MediaSession.PositionState) {
+ assertThat(
+ "Duration should match",
+ state.duration,
+ closeTo(TEST_DURATION1, 0.01))
+
+ assertThat(
+ "Playback rate should match",
+ state.playbackRate,
+ closeTo(1.0, 0.01))
+
+ assertThat(
+ "Position should be >= 0",
+ state.position,
+ greaterThanOrEqualTo(0.0))
+ }
+
+ @AssertCalled(count = 5, order = [2])
+ override fun onPlay(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPlayCalled[sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+
+ @AssertCalled(count = 5)
+ override fun onPause(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPauseCalled[sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+ })
+
+ sessionRule.waitForResult(completedStep2)
+ mediaSession1!!.pause()
+
+ sessionRule.waitForResult(completedStep3)
+ mediaSession1!!.play()
+
+ sessionRule.waitForResult(completedStep4)
+ sessionRule.waitForResult(completedStep5)
+ mediaSession1!!.pause()
+ mediaSession1!!.nextTrack()
+ mediaSession1!!.play()
+
+ sessionRule.waitForResult(completedStep6)
+ mediaSession1!!.pause()
+ mediaSession1!!.nextTrack()
+ mediaSession1!!.play()
+
+ sessionRule.waitForResult(completedStep7)
+ mediaSession1!!.pause()
+
+ sessionRule.waitForResult(completedStep8a)
+ mediaSession1!!.previousTrack()
+ mediaSession1!!.play()
+
+ sessionRule.waitForResult(completedStep8b)
+ sessionRule.waitForResult(completedStep9)
+ }
+
+ @Test
+ fun defaultMetadataPlayback() {
+ val onActivatedCalled = arrayOf(GeckoResult<Void>())
+ val onPlayCalled = arrayOf(GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>())
+ val onPauseCalled = arrayOf(GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>())
+
+ // Test:
+ // 1. Load Media Session page which contains 1 audio track.
+ // 2. Track 1 is played on page load.
+ // a. Ensure onActivated is called.
+ // b. Ensure onPlay (1) is called.
+ val completedStep2 = GeckoResult.allOf(
+ onActivatedCalled[0],
+ onPlayCalled[0])
+
+ // 3. Pause playback of track 1.
+ // a. Ensure onPause (1) is called.
+ val completedStep3 = GeckoResult.allOf(
+ onPauseCalled[0])
+
+ // 4. Resume playback (1).
+ // b. Ensure onPlay (1) is called.
+ val completedStep4 = GeckoResult.allOf(
+ onPlayCalled[1])
+
+ // 5. Wait for track 1 end.
+ // a. Ensure onPause (1) is called.
+ val completedStep5 = GeckoResult.allOf(
+ onPauseCalled[1])
+
+ val path = MEDIA_SESSION_DEFAULT1_PATH
+ val session1 = sessionRule.createOpenSession()
+
+ var mediaSession1 : MediaSession? = null
+ // 1.
+ session1.loadTestPath(path)
+
+ session1.delegateUntilTestEnd(object : Callbacks.MediaSessionDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onActivated(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onActivatedCalled[0].complete(null)
+ mediaSession1 = mediaSession
+ }
+
+ @AssertCalled(count = 2, order = [2])
+ override fun onPlay(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPlayCalled[sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+
+ @AssertCalled(count = 2)
+ override fun onPause(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPauseCalled[sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+ })
+
+ sessionRule.waitForResult(completedStep2)
+ mediaSession1!!.pause()
+
+ sessionRule.waitForResult(completedStep3)
+ mediaSession1!!.play()
+
+ sessionRule.waitForResult(completedStep4)
+ sessionRule.waitForResult(completedStep5)
+ }
+
+ @Test
+ fun domMultiSessions() {
+ val onActivatedCalled = arrayOf(
+ arrayOf(GeckoResult<Void>()),
+ arrayOf(GeckoResult<Void>()))
+ val onMetadataCalled = arrayOf(
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>()),
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>()))
+ val onPlayCalled = arrayOf(
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>()),
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>()))
+ val onPauseCalled = arrayOf(
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>()),
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>()))
+
+ // Test:
+ // 1. Session1: Load DOM Media Session page with 3 audio tracks.
+ // 2. Session1: Track 1 is played on page load.
+ // a. Session1: Ensure onActivated is called.
+ // b. Session1: Ensure onMetadata (1) is called.
+ // c. Session1: Ensure onPlay (1) is called.
+ // d. Session1: Verify isActive.
+ val completedStep2 = GeckoResult.allOf(
+ onActivatedCalled[0][0],
+ onMetadataCalled[0][0],
+ onPlayCalled[0][0])
+
+ // 3. Session1: Pause playback of track 1.
+ // a. Session1: Ensure onPause (1) is called.
+ val completedStep3 = GeckoResult.allOf(
+ onPauseCalled[0][0])
+
+ // 4. Session2: Load DOM Media Session page with 3 audio tracks.
+ // 5. Session2: Track 1 is played on page load.
+ // a. Session2: Ensure onActivated is called.
+ // b. Session2: Ensure onMetadata (1) is called.
+ // c. Session2: Ensure onPlay (1) is called.
+ // d. Session2: Verify isActive.
+ val completedStep5 = GeckoResult.allOf(
+ onActivatedCalled[1][0],
+ onMetadataCalled[1][0],
+ onPlayCalled[1][0])
+
+ // 6. Session2: Pause playback of track 1.
+ // a. Session2: Ensure onPause (1) is called.
+ val completedStep6 = GeckoResult.allOf(
+ onPauseCalled[1][0])
+
+ // 7. Session1: Play next track (2).
+ // a. Session1: Ensure onMetadata (2) is called.
+ // b. Session1: Ensure onPlay (2) is called.
+ val completedStep7 = GeckoResult.allOf(
+ onMetadataCalled[0][1],
+ onPlayCalled[0][1])
+
+ // 8. Session1: wait for track 1 end.
+ // a. Ensure onPause (1) is called.
+ val completedStep8 = GeckoResult.allOf(
+ onPauseCalled[0][1])
+
+ val path = MEDIA_SESSION_DOM1_PATH
+ val session1 = sessionRule.createOpenSession()
+ val session2 = sessionRule.createOpenSession()
+ var mediaSession1 : MediaSession? = null
+ var mediaSession2 : MediaSession? = null
+
+ session1.delegateUntilTestEnd(object : Callbacks.MediaSessionDelegate {
+ @AssertCalled(count = 1)
+ override fun onActivated(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onActivatedCalled[0][sessionRule.currentCall.counter - 1]
+ .complete(null)
+ mediaSession1 = mediaSession
+
+ assertThat(
+ "Should be active",
+ mediaSession1?.isActive,
+ equalTo(true))
+ }
+
+ @AssertCalled
+ override fun onPositionState(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ state: MediaSession.PositionState) {
+ assertThat(
+ "Duration should match",
+ state.duration,
+ closeTo(TEST_DURATION1, 0.01))
+
+ assertThat(
+ "Playback rate should match",
+ state.playbackRate,
+ closeTo(1.0, 0.01))
+
+ assertThat(
+ "Position should be >= 0",
+ state.position,
+ greaterThanOrEqualTo(0.0))
+ }
+
+ @AssertCalled
+ override fun onFeatures(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ features: Long) {
+
+ val play = (features and MediaSession.Feature.PLAY) != 0L
+ val pause = (features and MediaSession.Feature.PAUSE) != 0L
+ val stop = (features and MediaSession.Feature.PAUSE) != 0L
+ val next = (features and MediaSession.Feature.PAUSE) != 0L
+ val prev = (features and MediaSession.Feature.PAUSE) != 0L
+
+ assertThat(
+ "Playback constrols should be supported",
+ play && pause && stop && next && prev,
+ equalTo(true))
+ }
+
+ @AssertCalled(count = 2)
+ override fun onMetadata(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ meta: MediaSession.Metadata) {
+ onMetadataCalled[0][sessionRule.currentCall.counter - 1]
+ .complete(null)
+
+ assertThat(
+ "Title should match",
+ meta.title,
+ equalTo(forEachCall(
+ DOM_META[0].title,
+ DOM_META[1].title)))
+ assertThat(
+ "Artist should match",
+ meta.artist,
+ equalTo(forEachCall(
+ DOM_META[0].artist,
+ DOM_META[1].artist)))
+ assertThat(
+ "Album should match",
+ meta.album,
+ equalTo(forEachCall(
+ DOM_META[0].album,
+ DOM_META[1].album)))
+ assertThat(
+ "Artwork image should be non-null",
+ meta.artwork!!.getBitmap(200),
+ notNullValue())
+ }
+
+ @AssertCalled(count = 2)
+ override fun onPlay(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPlayCalled[0][sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+
+ @AssertCalled(count = 2)
+ override fun onPause(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPauseCalled[0][sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+ })
+
+ session2.delegateUntilTestEnd(object : Callbacks.MediaSessionDelegate {
+ @AssertCalled(count = 1)
+ override fun onActivated(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onActivatedCalled[1][sessionRule.currentCall.counter - 1]
+ .complete(null)
+ mediaSession2 = mediaSession;
+
+ assertThat(
+ "Should be active",
+ mediaSession1!!.isActive,
+ equalTo(true))
+ assertThat(
+ "Should be active",
+ mediaSession2!!.isActive,
+ equalTo(true))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onMetadata(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ meta: MediaSession.Metadata) {
+ onMetadataCalled[1][sessionRule.currentCall.counter - 1]
+ .complete(null)
+
+ assertThat(
+ "Title should match",
+ meta.title,
+ equalTo(forEachCall(
+ DOM_META[0].title)))
+ assertThat(
+ "Artist should match",
+ meta.artist,
+ equalTo(forEachCall(
+ DOM_META[0].artist)))
+ assertThat(
+ "Album should match",
+ meta.album,
+ equalTo(forEachCall(
+ DOM_META[0].album)))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPlay(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPlayCalled[1][sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPause(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPauseCalled[1][sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+ })
+
+ session1.loadTestPath(path)
+ sessionRule.waitForResult(completedStep2)
+
+ mediaSession1!!.pause()
+ sessionRule.waitForResult(completedStep3)
+
+ session2.loadTestPath(path)
+ sessionRule.waitForResult(completedStep5)
+
+ mediaSession2!!.pause()
+ sessionRule.waitForResult(completedStep6)
+
+ mediaSession1!!.pause()
+ mediaSession1!!.nextTrack()
+ mediaSession1!!.play()
+ sessionRule.waitForResult(completedStep7)
+ sessionRule.waitForResult(completedStep8)
+ }
+
+ @Test
+ fun fullscreenVideoElementMetadata() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "media.autoplay.default" to 0,
+ "full-screen-api.allow-trusted-requests-only" to false))
+
+ val onActivatedCalled = GeckoResult<Void>()
+ val onPlayCalled = GeckoResult<Void>()
+ val onPauseCalled = GeckoResult<Void>()
+ val onFullscreenCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>())
+
+ // Test:
+ // 1. Load video test page which contains 1 video element.
+ // a. Ensure page has loaded.
+ // 2. Play video element.
+ // a. Ensure onActivated is called.
+ // b. Ensure onPlay is called.
+ val completedStep2 = GeckoResult.allOf(
+ onActivatedCalled,
+ onPlayCalled)
+
+ // 3. Enter fullscreen of the video.
+ // a. Ensure onFullscreen is called.
+ val completedStep3 = GeckoResult.allOf(
+ onFullscreenCalled[0])
+
+ // 4. Exit fullscreen of the video.
+ // a. Ensure onFullscreen is called.
+ val completedStep4 = GeckoResult.allOf(
+ onFullscreenCalled[1])
+
+ // 5. Pause the video.
+ // a. Ensure onPause is called.
+ val completedStep5 = GeckoResult.allOf(
+ onPauseCalled)
+
+ var mediaSession1 : MediaSession? = null
+
+ val path = VIDEO_WEBM_PATH
+ val session1 = sessionRule.createOpenSession()
+
+ session1.delegateUntilTestEnd(object : Callbacks.MediaSessionDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onActivated(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ mediaSession1 = mediaSession
+
+ onActivatedCalled.complete(null)
+
+ assertThat(
+ "Should be active",
+ mediaSession.isActive,
+ equalTo(true))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onPlay(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPlayCalled.complete(null)
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPause(
+ session: GeckoSession,
+ mediaSession: MediaSession) {
+ onPauseCalled.complete(null)
+ }
+
+ @AssertCalled(count = 2)
+ override fun onFullscreen(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ enabled: Boolean,
+ meta: MediaSession.ElementMetadata?) {
+ if (sessionRule.currentCall.counter == 1) {
+ assertThat(
+ "Fullscreen should be enabled",
+ enabled,
+ equalTo(true))
+ assertThat(
+ "Element metadata should exist",
+ meta,
+ notNullValue())
+ assertThat(
+ "Duration should match",
+ meta!!.duration,
+ closeTo(WEBM_TEST_DURATION, 0.01))
+ assertThat(
+ "Width should match",
+ meta.width,
+ equalTo(WEBM_TEST_WIDTH))
+ assertThat(
+ "Height should match",
+ meta.height,
+ equalTo(WEBM_TEST_HEIGHT))
+ assertThat(
+ "Audio track count should match",
+ meta.audioTrackCount,
+ equalTo(1))
+ assertThat(
+ "Video track count should match",
+ meta.videoTrackCount,
+ equalTo(1))
+
+ } else {
+ assertThat(
+ "Fullscreen should be disabled",
+ enabled,
+ equalTo(false))
+ }
+
+ onFullscreenCalled[sessionRule.currentCall.counter - 1]
+ .complete(null)
+ }
+ })
+
+ // 1.
+ session1.loadTestPath(path)
+ sessionRule.waitForPageStop()
+
+ // 2.
+ session1.evaluateJS("document.querySelector('video').play()")
+ sessionRule.waitForResult(completedStep2)
+
+ // 3.
+ session1.evaluateJS(
+ "document.querySelector('video').requestFullscreen()")
+ sessionRule.waitForResult(completedStep3)
+
+ // 4.
+ session1.evaluateJS("document.exitFullscreen()")
+ sessionRule.waitForResult(completedStep4)
+
+ // 5.
+ mediaSession1!!.pause()
+ sessionRule.waitForResult(completedStep5)
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java
new file mode 100644
index 0000000000..a6b1c1d892
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java
@@ -0,0 +1,212 @@
+package org.mozilla.geckoview.test;
+
+import androidx.test.filters.MediumTest;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.MultiMap;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.assertThat;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+
+@RunWith(AndroidJUnit4.class)
+@MediumTest
+public class MultiMapTest {
+ @Test
+ public void emptyMap() {
+ final MultiMap<String, String> map = new MultiMap<>();
+
+ assertThat(map.get("not-present").isEmpty(), is(true));
+ assertThat(map.containsKey("not-present"), is(false));
+ assertThat(map.containsEntry("not-present", "nope"), is(false));
+ assertThat(map.size(), is(0));
+ assertThat(map.asMap().size(), is(0));
+ assertThat(map.remove("not-present"), nullValue());
+ assertThat(map.remove("not-present", "nope"), is(false));
+ assertThat(map.keySet().size(), is(0));
+
+ map.clear();
+ }
+
+ @Test
+ public void emptyMapWithCapacity() {
+ final MultiMap<String, String> map = new MultiMap<>(10);
+
+ assertThat(map.get("not-present").isEmpty(), is(true));
+ assertThat(map.containsKey("not-present"), is(false));
+ assertThat(map.containsEntry("not-present", "nope"), is(false));
+ assertThat(map.size(), is(0));
+ assertThat(map.asMap().size(), is(0));
+ assertThat(map.remove("not-present"), nullValue());
+ assertThat(map.remove("not-present", "nope"), is(false));
+ assertThat(map.keySet().size(), is(0));
+
+ map.clear();
+ }
+
+ @Test
+ public void addMultipleValues() {
+ final MultiMap<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ assertThat(map.containsEntry("test", "value1"), is(true));
+ assertThat(map.containsEntry("test", "value2"), is(true));
+ assertThat(map.containsEntry("test2", "value3"), is(true));
+
+ assertThat(map.containsEntry("test3", "value1"), is(false));
+ assertThat(map.containsEntry("test", "value3"), is(false));
+
+ List<String> values = map.get("test");
+ assertThat(values.contains("value1"), is(true));
+ assertThat(values.contains("value2"), is(true));
+ assertThat(values.contains("value3"), is(false));
+ assertThat(values.size(), is(2));
+
+ List<String> values2 = map.get("test2");
+ assertThat(values2.contains("value1"), is(false));
+ assertThat(values2.contains("value2"), is(false));
+ assertThat(values2.contains("value3"), is(true));
+ assertThat(values2.size(), is(1));
+
+ assertThat(map.size(), is(2));
+ }
+
+ @Test
+ public void remove() {
+ final MultiMap<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ assertThat(map.size(), is(2));
+
+ List<String> values = map.remove("test");
+
+ assertThat(values.size(), is(2));
+ assertThat(values.contains("value1"), is(true));
+ assertThat(values.contains("value2"), is(true));
+
+ assertThat(map.size(), is(1));
+
+ assertThat(map.containsKey("test"), is(false));
+ assertThat(map.containsEntry("test", "value1"), is(false));
+ assertThat(map.containsEntry("test", "value2"), is(false));
+ assertThat(map.get("test").size(), is(0));
+
+ assertThat(map.get("test2").size(), is(1));
+ assertThat(map.get("test2").contains("value3"), is(true));
+ assertThat(map.containsEntry("test2", "value3"), is(true));
+ }
+
+ @Test
+ public void removeAllValuesRemovesKey() {
+ final MultiMap<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ assertThat(map.remove("test", "value1"), is(true));
+ assertThat(map.containsEntry("test", "value1"), is(false));
+ assertThat(map.containsEntry("test", "value2"), is(true));
+ assertThat(map.get("test").size(), is(1));
+ assertThat(map.get("test").contains("value2"), is(true));
+
+ assertThat(map.remove("test", "value2"), is(true));
+
+ assertThat(map.remove("test", "value3"), is(false));
+ assertThat(map.remove("test2", "value4"), is(false));
+
+ assertThat(map.containsKey("test"), is(false));
+ assertThat(map.containsKey("test2"), is(true));
+ }
+
+ @Test
+ public void keySet() {
+ final MultiMap<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ Set<String> keys = map.keySet();
+
+ assertThat(keys.size(), is(2));
+ assertThat(keys.contains("test"), is(true));
+ assertThat(keys.contains("test2"), is(true));
+ }
+
+ @Test
+ public void clear() {
+ final MultiMap<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ assertThat(map.size(), is(2));
+
+ map.clear();
+
+ assertThat(map.size(), is(0));
+ assertThat(map.containsKey("test"), is(false));
+ assertThat(map.containsKey("test2"), is(false));
+ assertThat(map.containsEntry("test", "value1"), is(false));
+ assertThat(map.containsEntry("test", "value2"), is(false));
+ assertThat(map.containsEntry("test2", "value3"), is(false));
+ }
+
+ @Test
+ public void asMap() {
+ final MultiMap<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ final Map<String, List<String>> asMap = map.asMap();
+
+ assertThat(asMap.size(), is(2));
+
+ assertThat(asMap.get("test").size(), is(2));
+ assertThat(asMap.get("test").contains("value1"), is(true));
+ assertThat(asMap.get("test").contains("value2"), is(true));
+
+ assertThat(asMap.get("test2").size(), is(1));
+ assertThat(asMap.get("test2").contains("value3"), is(true));
+ }
+
+ @Test
+ public void addAll() {
+ final MultiMap<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+
+ assertThat(map.get("test").size(), is(1));
+
+ // Existing key test
+ List<String> values = map.addAll("test", Arrays.asList("value2", "value3"));
+
+ assertThat(values.size(), is(3));
+ assertThat(values.contains("value1"), is(true));
+ assertThat(values.contains("value2"), is(true));
+ assertThat(values.contains("value3"), is(true));
+
+ assertThat(map.containsEntry("test", "value1"), is(true));
+ assertThat(map.containsEntry("test", "value2"), is(true));
+ assertThat(map.containsEntry("test", "value3"), is(true));
+
+ // New key test
+ List<String> values2 = map.addAll("test2", Arrays.asList("value4", "value5"));
+ assertThat(values2.size(), is(2));
+ assertThat(values2.contains("value4"), is(true));
+ assertThat(values2.contains("value5"), is(true));
+
+ assertThat(map.containsEntry("test2", "value4"), is(true));
+ assertThat(map.containsEntry("test2", "value5"), is(true));
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt
new file mode 100644
index 0000000000..554f69286d
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt
@@ -0,0 +1,1972 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.os.SystemClock
+import android.view.KeyEvent
+import android.util.Base64
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.GeckoSession.*
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.*
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class NavigationDelegateTest : BaseSessionTest() {
+
+ // Provides getters for Loader
+ class TestLoader : Loader() {
+ var mUri: String? = null
+ override fun uri(uri: String): TestLoader {
+ mUri = uri
+ super.uri(uri)
+ return this
+ }
+ fun getUri() : String? {
+ return mUri
+ }
+ override fun flags(f: Int): TestLoader {
+ super.flags(f)
+ return this
+ }
+ }
+
+ fun testLoadErrorWithErrorPage(testLoader: TestLoader, expectedCategory: Int,
+ expectedError: Int,
+ errorPageUrl: String?) {
+ sessionRule.delegateDuringNextWait(
+ object : Callbacks.ProgressDelegate, Callbacks.NavigationDelegate, Callbacks.ContentDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URI should be " + testLoader.getUri(), request.uri,
+ equalTo(testLoader.getUri()))
+ assertThat("App requested this load", request.isDirectNavigation,
+ equalTo(true))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URI should be " + testLoader.getUri(), url,
+ equalTo(testLoader.getUri()))
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onLoadError(session: GeckoSession, uri: String?,
+ error: WebRequestError): GeckoResult<String>? {
+ assertThat("Error category should match", error.category,
+ equalTo(expectedCategory))
+ assertThat("Error code should match", error.code,
+ equalTo(expectedError))
+ return GeckoResult.fromValue(errorPageUrl)
+ }
+
+ @AssertCalled(count = 1, order = [4])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should fail", success, equalTo(false))
+ }
+ })
+
+ sessionRule.session.load(testLoader)
+ sessionRule.waitForPageStop()
+
+ if (errorPageUrl != null) {
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate, Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match", url, equalTo(testLoader.getUri()))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should not be empty", title, not(isEmptyOrNullString()))
+ }
+ })
+ }
+ }
+
+ fun testLoadExpectError(testUri: String, expectedCategory: Int,
+ expectedError: Int) {
+ testLoadExpectError(TestLoader().uri(testUri), expectedCategory, expectedError)
+ }
+
+ fun testLoadExpectError(testLoader: TestLoader, expectedCategory: Int,
+ expectedError: Int) {
+ testLoadErrorWithErrorPage(testLoader, expectedCategory,
+ expectedError, createTestUrl(HELLO_HTML_PATH))
+ testLoadErrorWithErrorPage(testLoader, expectedCategory,
+ expectedError, null)
+ }
+
+ fun testLoadEarlyErrorWithErrorPage(testUri: String, expectedCategory: Int,
+ expectedError: Int,
+ errorPageUrl: String?) {
+ sessionRule.delegateDuringNextWait(
+ object : Callbacks.ProgressDelegate, Callbacks.NavigationDelegate, Callbacks.ContentDelegate {
+
+ @AssertCalled(false)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URI should be " + testUri, url, equalTo(testUri))
+ }
+
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadError(session: GeckoSession, uri: String?,
+ error: WebRequestError): GeckoResult<String>? {
+ assertThat("Error category should match", error.category,
+ equalTo(expectedCategory))
+ assertThat("Error code should match", error.code,
+ equalTo(expectedError))
+ return GeckoResult.fromValue(errorPageUrl)
+ }
+
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+
+ sessionRule.session.loadUri(testUri)
+ sessionRule.waitUntilCalled(Callbacks.NavigationDelegate::class, "onLoadError")
+
+ if (errorPageUrl != null) {
+ sessionRule.waitUntilCalled(object: Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should not be empty", title, not(isEmptyOrNullString()))
+ }
+ })
+ }
+ }
+
+ fun testLoadEarlyError(testUri: String, expectedCategory: Int,
+ expectedError: Int) {
+ testLoadEarlyErrorWithErrorPage(testUri, expectedCategory, expectedError, createTestUrl(HELLO_HTML_PATH))
+ testLoadEarlyErrorWithErrorPage(testUri, expectedCategory, expectedError, null)
+ }
+
+ @Test fun loadFileNotFound() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ testLoadExpectError("file:///test.mozilla",
+ WebRequestError.ERROR_CATEGORY_URI,
+ WebRequestError.ERROR_FILE_NOT_FOUND)
+
+ val promise = mainSession.evaluatePromiseJS("document.addCertException(false)")
+ var exceptionCaught = false
+ try {
+ val result = promise.value as Boolean
+ assertThat("Promise should not resolve", result, equalTo(false))
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ exceptionCaught = true;
+ }
+ assertThat("document.addCertException failed with exception", exceptionCaught, equalTo(true))
+ }
+
+ @Test fun loadUnknownHost() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ testLoadExpectError(UNKNOWN_HOST_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ WebRequestError.ERROR_UNKNOWN_HOST)
+ }
+
+ // External loads should not have access to privileged protocols
+ @Test fun loadExternalDenied() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ testLoadExpectError(TestLoader().uri("file:///").flags(LOAD_FLAGS_EXTERNAL),
+ WebRequestError.ERROR_CATEGORY_UNKNOWN,
+ WebRequestError.ERROR_UNKNOWN)
+ testLoadExpectError(TestLoader().uri("resource://gre/").flags(LOAD_FLAGS_EXTERNAL),
+ WebRequestError.ERROR_CATEGORY_UNKNOWN,
+ WebRequestError.ERROR_UNKNOWN)
+ testLoadExpectError(TestLoader().uri("about:about").flags(LOAD_FLAGS_EXTERNAL),
+ WebRequestError.ERROR_CATEGORY_UNKNOWN,
+ WebRequestError.ERROR_UNKNOWN)
+ }
+
+ @Test fun loadInvalidUri() {
+ testLoadEarlyError(INVALID_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ WebRequestError.ERROR_MALFORMED_URI)
+ }
+
+ @Test fun loadBadPort() {
+ testLoadEarlyError("http://localhost:1/",
+ WebRequestError.ERROR_CATEGORY_NETWORK,
+ WebRequestError.ERROR_PORT_BLOCKED)
+ }
+
+ @Test fun loadUntrusted() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val host = if (sessionRule.env.isAutomation) {
+ "expired.example.com"
+ } else {
+ "expired.badssl.com"
+ }
+ val uri = "https://$host/"
+ testLoadExpectError(uri,
+ WebRequestError.ERROR_CATEGORY_SECURITY,
+ WebRequestError.ERROR_SECURITY_BAD_CERT)
+
+ mainSession.waitForJS("document.addCertException(false)")
+ mainSession.delegateDuringNextWait(
+ object : Callbacks.ProgressDelegate, Callbacks.NavigationDelegate, Callbacks.ContentDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URI should be " + uri, url, equalTo(uri))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should succeed", success, equalTo(true))
+ sessionRule.removeCertOverride(host, -1)
+ }
+ })
+ mainSession.evaluateJS("location.reload()")
+ mainSession.waitForPageStop()
+ }
+
+ @Test fun loadDeprecatedTls() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ // Load an initial generic error page in order to ensure 'allowDeprecatedTls' is false
+ testLoadExpectError(UNKNOWN_HOST_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ WebRequestError.ERROR_UNKNOWN_HOST)
+ mainSession.evaluateJS("document.allowDeprecatedTls = false")
+
+ val uri = if (sessionRule.env.isAutomation) {
+ "https://tls1.example.com/"
+ } else {
+ "https://tls-v1-0.badssl.com:1010/"
+ }
+ testLoadExpectError(uri,
+ WebRequestError.ERROR_CATEGORY_SECURITY,
+ WebRequestError.ERROR_SECURITY_SSL)
+
+ mainSession.delegateDuringNextWait(object : Callbacks.ProgressDelegate, Callbacks.NavigationDelegate {
+ @AssertCalled(count = 0)
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should be successful", success, equalTo(true))
+ }
+ })
+
+ mainSession.evaluateJS("document.allowDeprecatedTls = true")
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @Ignore // Disabled for bug 1619344.
+ @Test fun loadUnknownProtocol() {
+ testLoadEarlyError(UNKNOWN_PROTOCOL_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ WebRequestError.ERROR_UNKNOWN_PROTOCOL)
+ }
+
+ @Test fun loadUnknownProtocolIframe() {
+ // Should match iframe URI from IFRAME_UNKNOWN_PROTOCOL
+ val iframeUri = "foo://bar"
+ sessionRule.session.loadTestPath(IFRAME_UNKNOWN_PROTOCOL)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest) : GeckoResult<AllowOrDeny>? {
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("URI should match", request.uri, endsWith(IFRAME_UNKNOWN_PROTOCOL))
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onSubframeLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("URI should match", request.uri, endsWith(iframeUri))
+ return null
+ }
+ })
+ }
+
+ @Setting(key = Setting.Key.USE_TRACKING_PROTECTION, value = "true")
+ @Ignore // TODO: Bug 1564373
+ @Test fun trackingProtection() {
+ val category = ContentBlocking.AntiTracking.TEST
+ sessionRule.runtime.settings.contentBlocking.setAntiTracking(category)
+ sessionRule.session.loadTestPath(TRACKERS_PATH)
+
+ sessionRule.waitUntilCalled(
+ object : Callbacks.ContentBlockingDelegate {
+ @AssertCalled(count = 3)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ assertThat("Category should be set",
+ event.antiTrackingCategory,
+ equalTo(category))
+ assertThat("URI should not be null", event.uri, notNullValue())
+ assertThat("URI should match", event.uri, endsWith("tracker.js"))
+ }
+
+ @AssertCalled(false)
+ override fun onContentLoaded(session: GeckoSession, event: ContentBlocking.BlockEvent) {
+ }
+ })
+
+ sessionRule.session.settings.useTrackingProtection = false
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.ContentBlockingDelegate {
+ @AssertCalled(false)
+ override fun onContentBlocked(session: GeckoSession,
+ event: ContentBlocking.BlockEvent) {
+ }
+
+ @AssertCalled(count = 3)
+ override fun onContentLoaded(session: GeckoSession, event: ContentBlocking.BlockEvent) {
+ assertThat("Category should be set",
+ event.antiTrackingCategory,
+ equalTo(category))
+ assertThat("URI should not be null", event.uri, notNullValue())
+ assertThat("URI should match", event.uri, endsWith("tracker.js"))
+ }
+ })
+ }
+
+ @Test fun redirectLoad() {
+ val redirectUri = if (sessionRule.env.isAutomation) {
+ "http://example.org/tests/junit/hello.html"
+ } else {
+ "http://jigsaw.w3.org/HTTP/300/Overview.html"
+ }
+ val uri = if (sessionRule.env.isAutomation) {
+ "http://example.org/tests/junit/simple_redirect.sjs?$redirectUri"
+ } else {
+ "http://jigsaw.w3.org/HTTP/300/301.html"
+ }
+
+ sessionRule.session.loadUri(uri)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 2, order = [1, 2])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("URL should match", request.uri,
+ equalTo(forEachCall(request.uri, redirectUri)))
+ assertThat("Trigger URL should be null", request.triggerUri,
+ nullValue())
+ assertThat("From app should be correct", request.isDirectNavigation,
+ equalTo(forEachCall(true, false)))
+ assertThat("Target should not be null", request.target, notNullValue())
+ assertThat("Target should match", request.target,
+ equalTo(GeckoSession.NavigationDelegate.TARGET_WINDOW_CURRENT))
+ assertThat("Redirect flag is set", request.isRedirect,
+ equalTo(forEachCall(false, true)))
+ return null
+ }
+ })
+ }
+
+ @Test fun redirectLoadIframe() {
+ val path = if (sessionRule.env.isAutomation) {
+ IFRAME_REDIRECT_AUTOMATION
+ } else {
+ IFRAME_REDIRECT_LOCAL
+ }
+
+ sessionRule.session.loadTestPath(path)
+ sessionRule.waitForPageStop()
+
+ // We shouldn't be firing onLoadRequest for iframes, including redirects.
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("App requested this load", request.isDirectNavigation, equalTo(true))
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("URI should match", request.uri, endsWith(path))
+ assertThat("isRedirect should match", request.isRedirect, equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 2)
+ override fun onSubframeLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("App did not request this load", request.isDirectNavigation, equalTo(false))
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("isRedirect should match", request.isRedirect,
+ equalTo(forEachCall(false, true)))
+ return null
+ }
+ })
+ }
+
+ @Test fun redirectDenyLoad() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val redirectUri = if (sessionRule.env.isAutomation) {
+ "http://example.org/tests/junit/hello.html"
+ } else {
+ "http://jigsaw.w3.org/HTTP/300/Overview.html"
+ }
+ val uri = if (sessionRule.env.isAutomation) {
+ "http://example.org/tests/junit/simple_redirect.sjs?$redirectUri"
+ } else {
+ "http://jigsaw.w3.org/HTTP/300/301.html"
+ }
+
+ sessionRule.delegateDuringNextWait(
+ object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 2, order = [1, 2])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("URL should match", request.uri,
+ equalTo(forEachCall(request.uri, redirectUri)))
+ assertThat("Trigger URL should be null", request.triggerUri,
+ nullValue())
+ assertThat("From app should be correct", request.isDirectNavigation,
+ equalTo(forEachCall(true, false)))
+ assertThat("Target should not be null", request.target, notNullValue())
+ assertThat("Target should match", request.target,
+ equalTo(GeckoSession.NavigationDelegate.TARGET_WINDOW_CURRENT))
+ assertThat("Redirect flag is set", request.isRedirect,
+ equalTo(forEachCall(false, true)))
+
+ return forEachCall(
+ GeckoResult.fromValue(AllowOrDeny.ALLOW),
+ GeckoResult.fromValue(AllowOrDeny.DENY))
+ }
+ })
+
+ sessionRule.session.loadUri(uri)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URL should match", url, equalTo(uri))
+ }
+ })
+ }
+
+ @Test fun redirectIntentLoad() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(true))
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ val redirectUri = "intent://test"
+ val uri = "http://example.org/tests/junit/simple_redirect.sjs?$redirectUri"
+
+ sessionRule.session.loadUri(uri)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 2, order = [1, 2])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URL should match", request.uri, equalTo(forEachCall(uri, redirectUri)))
+ assertThat("From app should be correct", request.isDirectNavigation,
+ equalTo(forEachCall(true, false)))
+ return null
+ }
+ })
+ }
+
+
+ @Test fun bypassClassifier() {
+ val phishingUri = "https://www.itisatrap.org/firefox/its-a-trap.html"
+ val category = ContentBlocking.SafeBrowsing.PHISHING
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category)
+
+ sessionRule.session.load(Loader()
+ .uri(phishingUri + "?bypass=true")
+ .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER))
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.NavigationDelegate {
+ @AssertCalled(false)
+ override fun onLoadError(session: GeckoSession, uri: String?,
+ error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun safebrowsingPhishing() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val phishingUri = "https://www.itisatrap.org/firefox/its-a-trap.html"
+ val category = ContentBlocking.SafeBrowsing.PHISHING
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category)
+
+ // Add query string to avoid bypassing classifier check because of cache.
+ testLoadExpectError(phishingUri + "?block=true",
+ WebRequestError.ERROR_CATEGORY_SAFEBROWSING,
+ WebRequestError.ERROR_SAFEBROWSING_PHISHING_URI)
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE)
+
+ sessionRule.session.loadUri(phishingUri + "?block=false")
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.NavigationDelegate {
+ @AssertCalled(false)
+ override fun onLoadError(session: GeckoSession, uri: String?,
+ error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun safebrowsingMalware() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val malwareUri = "https://www.itisatrap.org/firefox/its-an-attack.html"
+ val category = ContentBlocking.SafeBrowsing.MALWARE
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category)
+
+ testLoadExpectError(malwareUri + "?block=true",
+ WebRequestError.ERROR_CATEGORY_SAFEBROWSING,
+ WebRequestError.ERROR_SAFEBROWSING_MALWARE_URI)
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE)
+
+ sessionRule.session.loadUri(malwareUri + "?block=false")
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.NavigationDelegate {
+ @AssertCalled(false)
+ override fun onLoadError(session: GeckoSession, uri: String?,
+ error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun safebrowsingUnwanted() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val unwantedUri = "https://www.itisatrap.org/firefox/unwanted.html"
+ val category = ContentBlocking.SafeBrowsing.UNWANTED
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category)
+
+ testLoadExpectError(unwantedUri + "?block=true",
+ WebRequestError.ERROR_CATEGORY_SAFEBROWSING,
+ WebRequestError.ERROR_SAFEBROWSING_UNWANTED_URI)
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE)
+
+ sessionRule.session.loadUri(unwantedUri + "?block=false")
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.NavigationDelegate {
+ @AssertCalled(false)
+ override fun onLoadError(session: GeckoSession, uri: String?,
+ error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun safebrowsingHarmful() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val harmfulUri = "https://www.itisatrap.org/firefox/harmful.html"
+ val category = ContentBlocking.SafeBrowsing.HARMFUL
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category)
+
+ testLoadExpectError(harmfulUri + "?block=true",
+ WebRequestError.ERROR_CATEGORY_SAFEBROWSING,
+ WebRequestError.ERROR_SAFEBROWSING_HARMFUL_URI)
+
+ sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE)
+
+ sessionRule.session.loadUri(harmfulUri + "?block=false")
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : Callbacks.NavigationDelegate {
+ @AssertCalled(false)
+ override fun onLoadError(session: GeckoSession, uri: String?,
+ error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+ })
+ }
+
+ // Checks that the User Agent matches the user agent built in
+ // nsHttpHandler::BuildUserAgent
+ @Test fun defaultUserAgentMatchesActualUserAgent() {
+ var userAgent = sessionRule.waitForResult(sessionRule.session.userAgent)
+ assertThat("Mobile user agent should match the default user agent",
+ userAgent, equalTo(GeckoSession.getDefaultUserAgent()))
+ }
+
+ @Test fun desktopMode() {
+ sessionRule.session.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ val mobileSubStr = "Mobile"
+ val desktopSubStr = "X11"
+
+ assertThat("User agent should be set to mobile",
+ getUserAgent(),
+ containsString(mobileSubStr))
+
+ var userAgent = sessionRule.waitForResult(sessionRule.session.userAgent)
+ assertThat("User agent should be reported as mobile",
+ userAgent, containsString(mobileSubStr))
+
+ sessionRule.session.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_DESKTOP
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should be set to desktop",
+ getUserAgent(),
+ containsString(desktopSubStr))
+
+ userAgent = sessionRule.waitForResult(sessionRule.session.userAgent)
+ assertThat("User agent should be reported as desktop",
+ userAgent, containsString(desktopSubStr))
+
+ sessionRule.session.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_MOBILE
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should be set to mobile",
+ getUserAgent(),
+ containsString(mobileSubStr))
+
+ userAgent = sessionRule.waitForResult(sessionRule.session.userAgent)
+ assertThat("User agent should be reported as mobile",
+ userAgent, containsString(mobileSubStr))
+
+ val vrSubStr = "Mobile VR"
+ sessionRule.session.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_VR
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should be set to VR",
+ getUserAgent(),
+ containsString(vrSubStr))
+
+ userAgent = sessionRule.waitForResult(sessionRule.session.userAgent)
+ assertThat("User agent should be reported as VR",
+ userAgent, containsString(vrSubStr))
+
+ }
+
+ private fun getUserAgent(session: GeckoSession = sessionRule.session): String {
+ return session.evaluateJS("window.navigator.userAgent") as String
+ }
+
+ @Test fun uaOverrideNewSession() {
+ val newSession = sessionRule.createClosedSession()
+ newSession.settings.userAgentOverride = "Test user agent override"
+
+ newSession.open()
+ newSession.loadUri("https://example.com")
+ newSession.waitForPageStop()
+
+ assertThat("User agent should match override", getUserAgent(newSession),
+ equalTo("Test user agent override"))
+ }
+
+ @Test fun uaOverride() {
+ sessionRule.session.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ val mobileSubStr = "Mobile"
+ val vrSubStr = "Mobile VR"
+ val overrideUserAgent = "This is the override user agent"
+
+ assertThat("User agent should be reported as mobile",
+ getUserAgent(), containsString(mobileSubStr))
+
+ sessionRule.session.settings.userAgentOverride = overrideUserAgent
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should be reported as override",
+ getUserAgent(), equalTo(overrideUserAgent))
+
+ sessionRule.session.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_VR
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should still be reported as override even when USER_AGENT_MODE is set",
+ getUserAgent(), equalTo(overrideUserAgent))
+
+ sessionRule.session.settings.userAgentOverride = null
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should now be reported as VR",
+ getUserAgent(), containsString(vrSubStr))
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ sessionRule.session.settings.userAgentOverride = overrideUserAgent
+ return null
+ }
+ })
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should be reported as override after being set in onLoadRequest",
+ getUserAgent(), equalTo(overrideUserAgent))
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ sessionRule.session.settings.userAgentOverride = null
+ return null
+ }
+ })
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ assertThat("User agent should again be reported as VR after disabling override in onLoadRequest",
+ getUserAgent(), containsString(vrSubStr))
+ }
+
+ @WithDisplay(width = 600, height = 200)
+ @Test fun viewportMode() {
+ sessionRule.session.loadTestPath(VIEWPORT_PATH)
+ sessionRule.waitForPageStop()
+
+ val desktopInnerWidth = 980.0
+ val physicalWidth = 600.0
+ val pixelRatio = sessionRule.session.evaluateJS("window.devicePixelRatio") as Double
+ val mobileInnerWidth = physicalWidth / pixelRatio
+ val innerWidthJs = "window.innerWidth"
+
+ var innerWidth = sessionRule.session.evaluateJS(innerWidthJs) as Double
+ assertThat("innerWidth should be equal to $mobileInnerWidth",
+ innerWidth, closeTo(mobileInnerWidth, 0.1))
+
+ sessionRule.session.settings.viewportMode = GeckoSessionSettings.VIEWPORT_MODE_DESKTOP
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ innerWidth = sessionRule.session.evaluateJS(innerWidthJs) as Double
+ assertThat("innerWidth should be equal to $desktopInnerWidth", innerWidth,
+ closeTo(desktopInnerWidth, 0.1))
+
+ sessionRule.session.settings.viewportMode = GeckoSessionSettings.VIEWPORT_MODE_MOBILE
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ innerWidth = sessionRule.session.evaluateJS(innerWidthJs) as Double
+ assertThat("innerWidth should be equal to $mobileInnerWidth again",
+ innerWidth, closeTo(mobileInnerWidth, 0.1))
+ }
+
+ @Test fun load() {
+ sessionRule.session.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("URI should match", request.uri, endsWith(HELLO_HTML_PATH))
+ assertThat("Trigger URL should be null", request.triggerUri,
+ nullValue())
+ assertThat("App requested this load", request.isDirectNavigation,
+ equalTo(true))
+ assertThat("Target should not be null", request.target, notNullValue())
+ assertThat("Target should match", request.target,
+ equalTo(GeckoSession.NavigationDelegate.TARGET_WINDOW_CURRENT))
+ assertThat("Redirect flag is not set", request.isRedirect, equalTo(false))
+ assertThat("Should not have a user gesture", request.hasUserGesture, equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URL should not be null", url, notNullValue())
+ assertThat("URL should match", url, endsWith(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go back", canGoBack, equalTo(false))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go forward", canGoForward, equalTo(false))
+ }
+
+ @AssertCalled(false)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun load_dataUri() {
+ val dataUrl = "data:,Hello%2C%20World!"
+ sessionRule.session.loadUri(dataUrl)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate, Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match the provided data URL", url, equalTo(dataUrl))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ @NullDelegate(GeckoSession.NavigationDelegate::class)
+ @Test fun load_withoutNavigationDelegate() {
+ // Test that when navigation delegate is disabled, we can still perform loads.
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+ }
+
+ @NullDelegate(GeckoSession.NavigationDelegate::class)
+ @Test fun load_canUnsetNavigationDelegate() {
+ // Test that if we unset the navigation delegate during a load, the load still proceeds.
+ var onLocationCount = 0
+ sessionRule.session.navigationDelegate = object : Callbacks.NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ onLocationCount++
+ }
+ }
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ assertThat("Should get callback for first load",
+ onLocationCount, equalTo(1))
+
+ sessionRule.session.reload()
+ sessionRule.session.navigationDelegate = null
+ sessionRule.session.waitForPageStop()
+
+ assertThat("Should not get callback for second load",
+ onLocationCount, equalTo(1))
+ }
+
+ @Test fun loadString() {
+ val dataString = "<html><head><title>TheTitle</title></head><body>TheBody</body></html>"
+ val mimeType = "text/html"
+ sessionRule.session.load(Loader().data(dataString, mimeType))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate, Callbacks.ProgressDelegate, Callbacks.ContentDelegate {
+ @AssertCalled
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should match", title, equalTo("TheTitle"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should be a data URL", url,
+ equalTo(createDataUri(dataString, mimeType)))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test fun loadString_noMimeType() {
+ sessionRule.session.load(Loader().data("Hello, World!", null))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate, Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should be a data URL", url, startsWith("data:"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test fun loadData_html() {
+ val bytes = getTestBytes(HELLO_HTML_PATH)
+ assertThat("test html should have data", bytes.size, greaterThan(0))
+
+ sessionRule.session.load(Loader().data(bytes, "text/html"))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate, Callbacks.ProgressDelegate, Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should match", title, equalTo("Hello, world!"))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match", url, equalTo(createDataUri(bytes, "text/html")))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ private fun createDataUri(data: String,
+ mimeType: String?): String {
+ return String.format("data:%s,%s", mimeType ?: "", data)
+ }
+
+ private fun createDataUri(bytes: ByteArray,
+ mimeType: String?): String {
+ return String.format("data:%s;base64,%s", mimeType ?: "",
+ Base64.encodeToString(bytes, Base64.NO_WRAP))
+ }
+
+ fun loadDataHelper(assetPath: String, mimeType: String? = null) {
+ val bytes = getTestBytes(assetPath)
+ assertThat("test data should have bytes", bytes.size, greaterThan(0))
+
+ sessionRule.session.load(Loader().data(bytes, mimeType))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate, Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match", url, equalTo(createDataUri(bytes, mimeType)))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully", success, equalTo(true))
+ }
+ })
+ }
+
+
+ @Test fun loadData() {
+ loadDataHelper("/assets/www/images/test.gif", "image/gif")
+ }
+
+ @Test fun loadData_noMimeType() {
+ loadDataHelper("/assets/www/images/test.gif")
+ }
+
+ @Test fun reload() {
+ sessionRule.session.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.reload()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URI should match", request.uri, endsWith(HELLO_HTML_PATH))
+ assertThat("Trigger URL should be null", request.triggerUri,
+ nullValue())
+ assertThat("Target should match", request.target,
+ equalTo(GeckoSession.NavigationDelegate.TARGET_WINDOW_CURRENT))
+ assertThat("Load should not be direct", request.isDirectNavigation,
+ equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match", url, endsWith(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Cannot go back", canGoBack, equalTo(false))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ assertThat("Cannot go forward", canGoForward, equalTo(false))
+ }
+
+ @AssertCalled(false)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun goBackAndForward() {
+ sessionRule.session.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH))
+ }
+ })
+
+ sessionRule.session.goBack()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 0, order = [1])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Load should not be direct", request.isDirectNavigation,
+ equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match", url, endsWith(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Cannot go back", canGoBack, equalTo(false))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ assertThat("Can go forward", canGoForward, equalTo(true))
+ }
+
+ @AssertCalled(false)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return null
+ }
+ })
+
+ sessionRule.session.goForward()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 0, order = [1])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Load should not be direct", request.isDirectNavigation,
+ equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Can go back", canGoBack, equalTo(true))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ assertThat("Cannot go forward", canGoForward, equalTo(false))
+ }
+
+ @AssertCalled(false)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun onLoadUri_returnTrueCancelsLoad() {
+ sessionRule.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ val res : AllowOrDeny
+ if (request.uri.endsWith(HELLO_HTML_PATH)) {
+ res = AllowOrDeny.DENY
+ } else {
+ res = AllowOrDeny.ALLOW
+ }
+ return GeckoResult.fromValue(res)
+ }
+ })
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.loadTestPath(HELLO2_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should succeed", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test fun onNewSession_calledForWindowOpen() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("window.open('newSession_child.html', '_blank')")
+
+ sessionRule.session.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URI should be correct", request.uri, endsWith(NEW_SESSION_CHILD_HTML_PATH))
+ assertThat("Trigger URL should match", request.triggerUri,
+ endsWith(NEW_SESSION_HTML_PATH))
+ assertThat("Target should be correct", request.target,
+ equalTo(GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW))
+ assertThat("Load should not be direct", request.isDirectNavigation,
+ equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ assertThat("URI should be correct", uri, endsWith(NEW_SESSION_CHILD_HTML_PATH))
+ return null
+ }
+ })
+ }
+
+ @Test(expected = GeckoSessionTestRule.RejectedPromiseException::class)
+ fun onNewSession_rejectLocal() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("window.open('file:///data/local/tmp', '_blank')")
+ }
+
+ @Test fun onNewSession_calledForTargetBlankLink() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("document.querySelector('#targetBlankLink').click()")
+
+ sessionRule.session.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ // We get two onLoadRequest calls for the link click,
+ // one when loading the URL and one when opening a new window.
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URI should be correct", request.uri, endsWith(NEW_SESSION_CHILD_HTML_PATH))
+ assertThat("Trigger URL should be null", request.triggerUri,
+ endsWith(NEW_SESSION_HTML_PATH))
+ assertThat("Target should be correct", request.target,
+ equalTo(GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ assertThat("URI should be correct", uri, endsWith(NEW_SESSION_CHILD_HTML_PATH))
+ return null
+ }
+ })
+ }
+
+ private fun delegateNewSession(settings: GeckoSessionSettings = mainSession.settings): GeckoSession {
+ val newSession = sessionRule.createClosedSession(settings)
+
+ sessionRule.session.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession> {
+ return GeckoResult.fromValue(newSession)
+ }
+ })
+
+ return newSession
+ }
+
+ @Test fun onNewSession_childShouldLoad() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ val newSession = delegateNewSession()
+ sessionRule.session.evaluateJS("document.querySelector('#targetBlankLink').click()")
+ // Initial about:blank
+ newSession.waitForPageStop()
+ // NEW_SESSION_CHILD_HTML_PATH
+ newSession.waitForPageStop()
+
+ newSession.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URL should match", url, endsWith(NEW_SESSION_CHILD_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should succeed", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test fun onNewSession_setWindowOpener() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ val newSession = delegateNewSession()
+ sessionRule.session.evaluateJS("document.querySelector('#targetBlankLink').click()")
+ newSession.waitForPageStop()
+
+ assertThat("window.opener should be set",
+ newSession.evaluateJS("window.opener.location.pathname") as String,
+ equalTo(NEW_SESSION_HTML_PATH))
+ }
+
+ @Test fun onNewSession_supportNoOpener() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ val newSession = delegateNewSession()
+ sessionRule.session.evaluateJS("document.querySelector('#noOpenerLink').click()")
+ newSession.waitForPageStop()
+
+ assertThat("window.opener should not be set",
+ newSession.evaluateJS("window.opener"),
+ equalTo(JSONObject.NULL))
+ }
+
+ @Test fun onNewSession_notCalledForHandledLoads() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ // Pretend we handled the target="_blank" link click.
+ val res : AllowOrDeny
+ if (request.uri.endsWith(NEW_SESSION_CHILD_HTML_PATH)) {
+ res = AllowOrDeny.DENY
+ } else {
+ res = AllowOrDeny.ALLOW
+ }
+ return GeckoResult.fromValue(res)
+ }
+ })
+
+ sessionRule.session.evaluateJS("document.querySelector('#targetBlankLink').click()")
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+
+ // Assert that onNewSession was not called for the link click.
+ sessionRule.session.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URI must match", request.uri,
+ endsWith(forEachCall(NEW_SESSION_CHILD_HTML_PATH, NEW_SESSION_HTML_PATH)))
+ assertThat("Load should not be direct", request.isDirectNavigation,
+ equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 0)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun onNewSession_submitFormWithTargetBlank() {
+ sessionRule.session.loadTestPath(FORM_BLANK_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.evaluateJS("""
+ document.querySelector('input[type=text]').focus()
+ """)
+ sessionRule.session.waitUntilCalled(GeckoSession.TextInputDelegate::class,
+ "restartInput")
+
+ val time = SystemClock.uptimeMillis()
+ val keyEvent = KeyEvent(time, time, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER, 0)
+ sessionRule.session.textInput.onKeyDown(KeyEvent.KEYCODE_ENTER, keyEvent)
+ sessionRule.session.textInput.onKeyUp(KeyEvent.KEYCODE_ENTER,
+ KeyEvent.changeAction(keyEvent,
+ KeyEvent.ACTION_UP))
+
+ sessionRule.session.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URL should be correct", request.uri,
+ endsWith("form_blank.html?"))
+ assertThat("Trigger URL should match", request.triggerUri,
+ endsWith("form_blank.html"))
+ assertThat("Target should be correct", request.target,
+ equalTo(GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onNewSession(session: GeckoSession, uri: String):
+ GeckoResult<GeckoSession>? {
+ assertThat("URL should be correct", uri, endsWith("form_blank.html?"))
+ return null
+ }
+ })
+ }
+
+ @Test fun loadUriReferrer() {
+ val uri = "https://example.com"
+ val referrer = "https://foo.org/"
+
+ sessionRule.session.load(Loader()
+ .uri(uri)
+ .referrer(referrer)
+ .flags(GeckoSession.LOAD_FLAGS_NONE))
+ sessionRule.session.waitForPageStop()
+
+ assertThat("Referrer should match",
+ sessionRule.session.evaluateJS("document.referrer") as String,
+ equalTo(referrer))
+ }
+
+ @Test fun loadUriReferrerSession() {
+ val uri = "https://example.com/bar"
+ val referrer = "https://example.org/foo"
+
+ sessionRule.session.loadUri(referrer)
+ sessionRule.session.waitForPageStop()
+
+ val newSession = sessionRule.createOpenSession()
+ newSession.load(Loader()
+ .uri(uri)
+ .referrer(sessionRule.session)
+ .flags(GeckoSession.LOAD_FLAGS_NONE))
+ newSession.waitForPageStop()
+
+ assertThat("Referrer should match",
+ newSession.evaluateJS("document.referrer") as String,
+ equalTo(referrer))
+ }
+
+ @Test fun loadUriReferrerSessionFileUrl() {
+ val uri = "file:///system/etc/fonts.xml"
+ val referrer = "https://example.org"
+
+ sessionRule.session.loadUri(referrer)
+ sessionRule.session.waitForPageStop()
+
+ val newSession = sessionRule.createOpenSession()
+ newSession.load(Loader()
+ .uri(uri)
+ .referrer(sessionRule.session)
+ .flags(GeckoSession.LOAD_FLAGS_NONE))
+ newSession.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+ })
+ }
+
+ private fun loadUriHeaderTest(headers: Map<String?,String?>,
+ additional: Map<String?, String?>,
+ filter: Int = GeckoSession.HEADER_FILTER_CORS_SAFELISTED) {
+ // First collect default headers with no override
+ sessionRule.session.loadUri("$TEST_ENDPOINT/anything")
+ sessionRule.session.waitForPageStop()
+
+ val defaultContent = sessionRule.session.evaluateJS("document.body.children[0].innerHTML") as String
+ val defaultBody = JSONObject(defaultContent)
+ val defaultHeaders = defaultBody.getJSONObject("headers").asMap<String>()
+
+ val expected = HashMap(additional)
+ for (key in defaultHeaders.keys) {
+ expected[key] = defaultHeaders[key]
+ if (additional.containsKey(key)) {
+ // TODO: Bug 1671294, headers should be replaced, not appended
+ expected[key] += ", " + additional[key]
+ }
+ }
+
+ // Now load the page with the header override
+ sessionRule.session.load(Loader()
+ .uri("$TEST_ENDPOINT/anything")
+ .additionalHeaders(headers)
+ .headerFilter(filter))
+ sessionRule.session.waitForPageStop()
+
+ val content = sessionRule.session.evaluateJS("document.body.children[0].innerHTML") as String
+ val body = JSONObject(content)
+ val actualHeaders = body.getJSONObject("headers").asMap<String>()
+
+ assertThat("Headers should match", expected as Map<String?, String?>,
+ equalTo(actualHeaders))
+ }
+
+ private fun testLoaderEquals(a: Loader, b: Loader, shouldBeEqual: Boolean) {
+ assertThat("Equal test", a == b, equalTo(shouldBeEqual))
+ assertThat("HashCode test", a.hashCode() == b.hashCode(),
+ equalTo(shouldBeEqual))
+ }
+
+ @Test fun loaderEquals() {
+ testLoaderEquals(
+ Loader().uri("http://test-uri-equals.com"),
+ Loader().uri("http://test-uri-equals.com"),
+ true)
+ testLoaderEquals(
+ Loader().uri("http://test-uri-equals.com"),
+ Loader().uri("http://test-uri-equalsx.com"),
+ false)
+
+ testLoaderEquals(
+ Loader().uri("http://test-uri-equals.com")
+ .flags(LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer("test-referrer"),
+ Loader().uri("http://test-uri-equals.com")
+ .flags(LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer("test-referrer"),
+ true)
+ testLoaderEquals(
+ Loader().uri("http://test-uri-equals.com")
+ .flags(LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer(sessionRule.session),
+ Loader().uri("http://test-uri-equals.com")
+ .flags(LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer("test-referrer"),
+ false)
+
+ testLoaderEquals(
+ Loader().referrer(sessionRule.session)
+ .data("testtest", "text/plain"),
+ Loader().referrer(sessionRule.session)
+ .data("testtest", "text/plain"),
+ true)
+ testLoaderEquals(
+ Loader().referrer(sessionRule.session)
+ .data("testtest", "text/plain"),
+ Loader().referrer("test-referrer")
+ .data("testtest", "text/plain"),
+ false)
+ }
+
+ @Test fun loadUriHeader() {
+ // Basic test
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value", "Header2" to "Value1, Value2"),
+ mapOf()
+ )
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value", "Header2" to "Value1, Value2"),
+ mapOf("Header1" to "Value", "Header2" to "Value1, Value2"),
+ GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE
+ )
+
+ // Empty value headers are ignored
+ loadUriHeaderTest(
+ mapOf("ValueLess1" to "", "ValueLess2" to null),
+ mapOf()
+ )
+
+ // Null key or special headers are ignored
+ loadUriHeaderTest(
+ mapOf(null to "BadNull",
+ "Connection" to "BadConnection",
+ "Host" to "BadHost"),
+ mapOf()
+ )
+
+ // Key or value cannot contain '\r\n'
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value",
+ "Header2" to "Value1, Value2",
+ "this\r\nis invalid" to "test value",
+ "test key" to "this\r\n is a no-no",
+ "what" to "what\r\nhost:amazon.com",
+ "Header3" to "Value1, Value2, Value3"
+ ),
+ mapOf()
+ )
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value",
+ "Header2" to "Value1, Value2",
+ "this\r\nis invalid" to "test value",
+ "test key" to "this\r\n is a no-no",
+ "what" to "what\r\nhost:amazon.com",
+ "Header3" to "Value1, Value2, Value3"
+ ),
+ mapOf("Header1" to "Value",
+ "Header2" to "Value1, Value2",
+ "Header3" to "Value1, Value2, Value3"),
+ GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE
+ )
+
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value",
+ "Header2" to "Value1, Value2",
+ "what" to "what\r\nhost:amazon.com"),
+ mapOf()
+ )
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value",
+ "Header2" to "Value1, Value2",
+ "what" to "what\r\nhost:amazon.com"),
+ mapOf("Header1" to "Value", "Header2" to "Value1, Value2"),
+ GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE
+ )
+
+ loadUriHeaderTest(
+ mapOf("what" to "what\r\nhost:amazon.com"),
+ mapOf()
+ )
+
+ loadUriHeaderTest(
+ mapOf("this\r\n" to "yes"),
+ mapOf()
+ )
+
+ // Connection and Host cannot be overriden, no matter the case spelling
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value1", "ConnEction" to "test", "connection" to "test2"),
+ mapOf()
+ )
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value1", "ConnEction" to "test", "connection" to "test2"),
+ mapOf("Header1" to "Value1"),
+ GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE
+ )
+
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value1", "connection" to "test2"),
+ mapOf()
+ )
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value1", "connection" to "test2"),
+ mapOf("Header1" to "Value1"),
+ GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE
+ )
+
+ loadUriHeaderTest(
+ mapOf("Header1 " to "Value1", "host" to "test2"),
+ mapOf()
+ )
+ loadUriHeaderTest(
+ mapOf("Header1 " to "Value1", "host" to "test2"),
+ mapOf("Header1" to "Value1"),
+ GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE
+ )
+
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value1", "host" to "test2"),
+ mapOf()
+ )
+ loadUriHeaderTest(
+ mapOf("Header1" to "Value1", "host" to "test2"),
+ mapOf("Header1" to "Value1"),
+ GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE
+ )
+
+ // Adding white space at the end of a forbidden header still prevents override
+ loadUriHeaderTest(
+ mapOf("host" to "amazon.com",
+ "host " to "amazon.com",
+ "host\r" to "amazon.com",
+ "host\r\n" to "amazon.com"),
+ mapOf()
+ )
+
+ // '\r' or '\n' are forbidden character even when not following each other
+ loadUriHeaderTest(
+ mapOf("abc\ra\n" to "amazon.com"),
+ mapOf()
+ )
+
+ // CORS Safelist test
+ loadUriHeaderTest(
+ mapOf("Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
+ "Accept" to "text/html",
+ "Content-Language" to "de-DE, en-CA",
+ "Content-Type" to "multipart/form-data; boundary=something"),
+ mapOf("Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
+ "Accept" to "text/html",
+ "Content-Language" to "de-DE, en-CA",
+ "Content-Type" to "multipart/form-data; boundary=something"),
+ GeckoSession.HEADER_FILTER_CORS_SAFELISTED
+ )
+
+ // CORS safelist doesn't allow Content-type image/svg
+ loadUriHeaderTest(
+ mapOf("Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
+ "Accept" to "text/html",
+ "Content-Language" to "de-DE, en-CA",
+ "Content-Type" to "image/svg; boundary=something"),
+ mapOf("Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
+ "Accept" to "text/html",
+ "Content-Language" to "de-DE, en-CA"),
+ GeckoSession.HEADER_FILTER_CORS_SAFELISTED
+ )
+ }
+
+ @Test(expected = GeckoResult.UncaughtException::class)
+ fun onNewSession_doesNotAllowOpened() {
+ // Disable popup blocker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(NEW_SESSION_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession> {
+ return GeckoResult.fromValue(sessionRule.createOpenSession())
+ }
+ })
+
+ sessionRule.session.evaluateJS("document.querySelector('#targetBlankLink').click()")
+
+ sessionRule.session.waitUntilCalled(GeckoSession.NavigationDelegate::class,
+ "onNewSession")
+ UiThreadUtils.loopUntilIdle(sessionRule.env.defaultTimeoutMillis)
+ }
+
+ @Test
+ fun extensionProcessSwitching() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+
+ val controller = sessionRule.runtime.webExtensionController
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtensionController.PromptDelegate::class,
+ controller::setPromptDelegate,
+ { controller.promptDelegate = null },
+ object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val extension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/page-history.xpi"))
+
+ assertThat("baseUrl should be a valid extension URL",
+ extension.metaData.baseUrl, startsWith("moz-extension://"))
+
+ val url = extension.metaData.baseUrl + "page.html"
+ processSwitchingTest(url)
+
+ sessionRule.waitForResult(controller.uninstall(extension))
+ }
+
+ @Test
+ fun mainProcessSwitching() {
+ processSwitchingTest("about:config")
+ }
+
+ private fun processSwitchingTest(url: String) {
+ val settings = sessionRule.runtime.settings
+ val aboutConfigEnabled = settings.aboutConfigEnabled
+ settings.aboutConfigEnabled = true
+
+ var currentUrl: String? = null
+ mainSession.delegateUntilTestEnd(object: GeckoSession.NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ currentUrl = url
+ }
+
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ assertThat("Should not get here", false, equalTo(true))
+ return null
+ }
+ })
+
+ // This will load a page in the child
+ mainSession.loadTestPath(HELLO2_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("docShell should start out active", mainSession.active,
+ equalTo(true))
+
+ // This loads in the parent process
+ mainSession.loadUri(url)
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, equalTo(url))
+
+ // This will load a page in the child
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, endsWith(HELLO_HTML_PATH))
+ assertThat("docShell should be active after switching process",
+ mainSession.active,
+ equalTo(true))
+
+ mainSession.loadUri(url)
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, equalTo(url))
+
+ sessionRule.session.goBack()
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, endsWith(HELLO_HTML_PATH))
+ assertThat("docShell should be active after switching process",
+ mainSession.active,
+ equalTo(true))
+
+ sessionRule.session.goBack()
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, equalTo(url))
+
+ sessionRule.session.goBack()
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, endsWith(HELLO2_HTML_PATH))
+ assertThat("docShell should be active after switching process",
+ mainSession.active,
+ equalTo(true))
+
+ settings.aboutConfigEnabled = aboutConfigEnabled
+ }
+
+ @Test fun setLocationHash() {
+ sessionRule.session.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.evaluateJS("location.hash = 'test1';")
+
+ sessionRule.session.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 0)
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("Load should not be direct", request.isDirectNavigation,
+ equalTo(false))
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URI should match", url, endsWith("#test1"))
+ }
+ })
+
+ sessionRule.session.evaluateJS("location.hash = 'test2';")
+
+ sessionRule.session.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 0)
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest):
+ GeckoResult<AllowOrDeny>? {
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URI should match", url, endsWith("#test2"))
+ }
+ })
+ }
+
+ @Test fun purgeHistory() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ sessionRule.session.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go back", canGoBack, equalTo(false))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go forward", canGoForward, equalTo(false))
+ }
+ })
+ sessionRule.session.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH")
+ sessionRule.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled(count = 1)
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go back", canGoBack, equalTo(true))
+ }
+ @AssertCalled(count = 1)
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go forward", canGoForward, equalTo(false))
+ }
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size, equalTo(2))
+ }
+ })
+ sessionRule.session.purgeHistory()
+ sessionRule.waitUntilCalled(object : Callbacks.All {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) {
+ assertThat("History should have one entry", state.size, equalTo(1))
+ }
+ @AssertCalled(count = 1)
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go back", canGoBack, equalTo(false))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Cannot go forward", canGoForward, equalTo(false))
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test fun userGesture() {
+ mainSession.loadUri("$TEST_ENDPOINT$CLICK_TO_RELOAD_HTML_PATH")
+ mainSession.waitForPageStop()
+
+ mainSession.synthesizeTap(50, 50)
+
+ sessionRule.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ assertThat("Should have a user gesture", request.hasUserGesture, equalTo(true))
+ assertThat("Load should not be direct", request.isDirectNavigation,
+ equalTo(false))
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+ }
+
+ @Test fun loadAfterLoad() {
+ // TODO: Bug 1657028
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ sessionRule.session.delegateDuringNextWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ assertThat("URLs should match", request.uri, endsWith(forEachCall(HELLO_HTML_PATH, HELLO2_HTML_PATH)))
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH")
+ mainSession.waitForPageStop()
+ }
+
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt
new file mode 100644
index 0000000000..0736b6a52d
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt
@@ -0,0 +1,143 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.not
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.gecko.util.ThreadUtils
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.TimeoutMillis
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class OpenWindowTest : BaseSessionTest() {
+
+ @Before
+ fun setup() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+
+ // Grant "desktop notification" permission
+ mainSession.delegateUntilTestEnd(object : Callbacks.PermissionDelegate {
+ override fun onContentPermissionRequest(session: GeckoSession, uri: String?, type: Int, callback: GeckoSession.PermissionDelegate.Callback) {
+ assertThat("Should grant DESKTOP_NOTIFICATIONS permission", type, equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+ callback.grant()
+ }
+ })
+ }
+
+ private fun openPageClickNotification() {
+ mainSession.loadTestPath(OPEN_WINDOW_PATH)
+ sessionRule.waitForPageStop()
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+ assertThat("Permission should be granted",
+ result as String, equalTo("granted"))
+
+ val runtime = sessionRule.runtime
+ val notificationResult = GeckoResult<Void>()
+ val register = { delegate: WebNotificationDelegate -> runtime.webNotificationDelegate = delegate}
+ val unregister = { _: WebNotificationDelegate -> runtime.webNotificationDelegate = null }
+ var notificationShown: WebNotification? = null
+
+ sessionRule.addExternalDelegateDuringNextWait(WebNotificationDelegate::class, register,
+ unregister, object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationShown = notification
+ notificationResult.complete(null)
+ }
+ })
+ mainSession.evaluateJS("showNotification()");
+ sessionRule.waitForResult(notificationResult)
+ notificationShown!!.click()
+ }
+
+ @Test
+ fun openWindowNullDelegate() {
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ContentDelegate, Callbacks.NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ // we should not open the target url
+ assertThat("URL should notmatch", url, not(createTestUrl(OPEN_WINDOW_TARGET_PATH)))
+ }
+ })
+ openPageClickNotification()
+ UiThreadUtils.loopUntilIdle(sessionRule.env.defaultTimeoutMillis)
+ }
+
+ @Test
+ fun openWindowNullResult() {
+ sessionRule.runtime.setServiceWorkerDelegate(object : GeckoRuntime.ServiceWorkerDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenWindow(url: String): GeckoResult<GeckoSession> {
+ ThreadUtils.assertOnUiThread()
+ return GeckoResult.fromValue(null)
+ }
+ })
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ContentDelegate, Callbacks.NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ // we should not open the target url
+ assertThat("URL should notmatch", url, not(createTestUrl(OPEN_WINDOW_TARGET_PATH)))
+ }
+ })
+ openPageClickNotification()
+ UiThreadUtils.loopUntilIdle(sessionRule.env.defaultTimeoutMillis)
+ }
+
+ @Test
+ fun openWindowSameSession() {
+ sessionRule.runtime.setServiceWorkerDelegate(object : GeckoRuntime.ServiceWorkerDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenWindow(url: String): GeckoResult<GeckoSession> {
+ ThreadUtils.assertOnUiThread()
+ return GeckoResult.fromValue(mainSession)
+ }
+ })
+ openPageClickNotification()
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate, Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("Should be on the main session", session, equalTo(mainSession))
+ assertThat("URL should match", url, equalTo(createTestUrl(OPEN_WINDOW_TARGET_PATH)))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Should be on the main session", session, equalTo(mainSession))
+ assertThat("Title should be correct", title, equalTo("Open Window test target"))
+ }
+ })
+ }
+
+ @Test
+ fun openWindowNewSession() {
+ var targetSession: GeckoSession? = null
+ sessionRule.runtime.setServiceWorkerDelegate(object : GeckoRuntime.ServiceWorkerDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenWindow(url: String): GeckoResult<GeckoSession> {
+ ThreadUtils.assertOnUiThread()
+ targetSession = sessionRule.createOpenSession()
+ return GeckoResult.fromValue(targetSession)
+ }
+ })
+ openPageClickNotification()
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate, Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("Should be on the target session", session, equalTo(targetSession))
+ assertThat("URL should match", url, equalTo(createTestUrl(OPEN_WINDOW_TARGET_PATH)))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Should be on the target session", session, equalTo(targetSession))
+ assertThat("Title should be correct", title, equalTo("Open Window test target"))
+ }
+ })
+ }
+} \ No newline at end of file
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt
new file mode 100644
index 0000000000..92002d894b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt
@@ -0,0 +1,498 @@
+package org.mozilla.geckoview.test
+
+import android.os.SystemClock
+import android.view.MotionEvent
+import org.mozilla.geckoview.ScreenLength
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.junit.Assume.assumeTrue
+import org.mozilla.geckoview.PanZoomController
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.util.Callbacks
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PanZoomControllerTest : BaseSessionTest() {
+ private val errorEpsilon = 3.0
+ private val scrollWaitTimeout = 10000.0 // 10 seconds
+
+ private fun setupDocument(documentPath: String) {
+ sessionRule.session.loadTestPath(documentPath)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+ sessionRule.session.flushApzRepaints()
+ }
+
+ private fun setupScroll() {
+ setupDocument(SCROLL_TEST_PATH)
+ }
+
+ private fun waitForVisualScroll(offset: Double, timeout: Double, param: String) {
+ mainSession.evaluateJS("""
+ new Promise((resolve, reject) => {
+ const start = Date.now();
+ function step() {
+ if (window.visualViewport.$param >= ($offset - $errorEpsilon)) {
+ resolve();
+ } else if ($timeout < (Date.now() - start)) {
+ reject();
+ } else {
+ window.requestAnimationFrame(step);
+ }
+ }
+ window.requestAnimationFrame(step);
+ });
+ """.trimIndent())
+ }
+
+ private fun waitForHorizontalScroll(offset: Double, timeout: Double) {
+ waitForVisualScroll(offset, timeout, "pageLeft")
+ }
+
+ private fun waitForVerticalScroll(offset: Double, timeout: Double) {
+ waitForVisualScroll(offset, timeout, "pageTop")
+ }
+
+
+ private fun scrollByVertical(mode: Int) {
+ setupScroll()
+ val vh = mainSession.evaluateJS("window.visualViewport.height") as Double
+ assertThat("Visual viewport height is not zero", vh, greaterThan(0.0))
+ sessionRule.session.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double
+ assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon))
+ }
+
+
+ private fun scrollByHorizontal(mode: Int) {
+ setupScroll()
+ val vw = mainSession.evaluateJS("window.visualViewport.width") as Double
+ assertThat("Visual viewport width is not zero", vw, greaterThan(0.0))
+ sessionRule.session.panZoomController.scrollBy(ScreenLength.fromVisualViewportWidth(1.0), ScreenLength.zero(), mode)
+ waitForHorizontalScroll(vw, scrollWaitTimeout)
+ val scrollX = mainSession.evaluateJS("window.visualViewport.pageLeft") as Double
+ assertThat("scrollBy should have scrolled along x axis one viewport", scrollX, closeTo(vw, errorEpsilon))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollByHorizontalSmooth() {
+ scrollByHorizontal(PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollByHorizontalAuto() {
+ scrollByHorizontal(PanZoomController.SCROLL_BEHAVIOR_AUTO)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollByVerticalSmooth() {
+ scrollByVertical(PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollByVerticalAuto() {
+ scrollByVertical(PanZoomController.SCROLL_BEHAVIOR_AUTO)
+ }
+
+ private fun scrollByVerticalTwice(mode: Int) {
+ setupScroll()
+ val vh = mainSession.evaluateJS("window.visualViewport.height") as Double
+ assertThat("Visual viewport height is not zero", vh, greaterThan(0.0))
+ sessionRule.session.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ sessionRule.session.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh * 2.0, scrollWaitTimeout)
+ val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double
+ assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh * 2.0, errorEpsilon))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollByVerticalTwiceSmooth() {
+ scrollByVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollByVerticalTwiceAuto() {
+ scrollByVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_AUTO)
+ }
+
+ private fun scrollToVertical(mode: Int) {
+ setupScroll()
+ val vh = mainSession.evaluateJS("window.visualViewport.height") as Double
+ assertThat("Visual viewport height is not zero", vh, greaterThan(0.0))
+ sessionRule.session.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double
+ assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon))
+ }
+
+
+ private fun scrollToHorizontal(mode: Int) {
+ setupScroll()
+ val vw = mainSession.evaluateJS("window.visualViewport.width") as Double
+ assertThat("Visual viewport width is not zero", vw, greaterThan(0.0))
+ sessionRule.session.panZoomController.scrollTo(ScreenLength.fromVisualViewportWidth(1.0), ScreenLength.zero(), mode)
+ waitForHorizontalScroll(vw, scrollWaitTimeout)
+ val scrollX = mainSession.evaluateJS("window.visualViewport.pageLeft") as Double
+ assertThat("scrollBy should have scrolled along x axis one viewport", scrollX, closeTo(vw, errorEpsilon))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToHorizontalSmooth() {
+ scrollToHorizontal(PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToHorizontalAuto() {
+ scrollToHorizontal(PanZoomController.SCROLL_BEHAVIOR_AUTO)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToVerticalSmooth() {
+ scrollToVertical(PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToVerticalAuto() {
+ scrollToVertical(PanZoomController.SCROLL_BEHAVIOR_AUTO)
+ }
+
+ private fun scrollToVerticalOnZoomedContent(mode: Int) {
+ setupScroll()
+
+ val originalVH = mainSession.evaluateJS("window.visualViewport.height") as Double
+ assertThat("Visual viewport height is not zero", originalVH, greaterThan(0.0))
+
+ val innerHeight = mainSession.evaluateJS("window.innerHeight") as Double
+ assertThat("Visual viewport height equals to window.innerHeight", originalVH, equalTo(innerHeight))
+
+ val originalScale = mainSession.evaluateJS("visualViewport.scale") as Double
+ assertThat("Visual viewport scale is the initial scale", originalScale, closeTo(0.5, 0.01))
+
+ // Change the resolution so that the visual viewport will be different from the layout viewport.
+ sessionRule.setResolutionAndScaleTo(2.0f)
+
+ val scale = mainSession.evaluateJS("visualViewport.scale") as Double
+ assertThat("Visual viewport scale is now greater than the initial scale", scale, greaterThan(originalScale))
+
+ val vh = mainSession.evaluateJS("window.visualViewport.height") as Double
+ assertThat("Visual viewport height has been changed", vh, lessThan(originalVH))
+
+ sessionRule.session.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double
+ assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToVerticalOnZoomedContentSmooth() {
+ scrollToVerticalOnZoomedContent(PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToVerticalOnZoomedContentAuto() {
+ scrollToVerticalOnZoomedContent(PanZoomController.SCROLL_BEHAVIOR_AUTO)
+ }
+
+ private fun scrollToVerticalTwice(mode: Int) {
+ setupScroll()
+ val vh = mainSession.evaluateJS("window.visualViewport.height") as Double
+ assertThat("Visual viewport height is not zero", vh, greaterThan(0.0))
+ sessionRule.session.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ sessionRule.session.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double
+ assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToVerticalTwiceSmooth() {
+ scrollToVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun scrollToVerticalTwiceAuto() {
+ scrollToVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_AUTO)
+ }
+
+ private fun setupTouch() {
+ setupDocument(TOUCH_HTML_PATH)
+ }
+
+ private fun sendDownEvent(x: Float, y: Float): GeckoResult<Int> {
+ val downTime = SystemClock.uptimeMillis();
+ val down = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, x, y, 0);
+
+ val result = mainSession.panZoomController.onTouchEventForResult(down)
+
+ val up = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0);
+
+ mainSession.panZoomController.onTouchEvent(up)
+
+ return result
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun touchEventForResultWithStaticToolbar() {
+ setupTouch()
+
+ // No touch handlers, without scrolling
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 15f))
+ assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_UNHANDLED))
+
+ // Touch handler with preventDefault
+ value = sessionRule.waitForResult(sendDownEvent(50f, 45f))
+ assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT))
+
+ // Touch handler without preventDefault
+ value = sessionRule.waitForResult(sendDownEvent(50f, 75f))
+ // Nothing should have done in the event handler and the content is not scrollable,
+ // thus the input result should be UNHANDLED, i.e. the dynamic toolbar should NOT
+ // move in response to the event.
+ assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_UNHANDLED))
+
+ // No touch handlers, with scrolling
+ setupScroll()
+ value = sessionRule.waitForResult(sendDownEvent(50f, 25f))
+ assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+
+ // Touch handler with scrolling
+ value = sessionRule.waitForResult(sendDownEvent(50f, 75f))
+ assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+ }
+
+ private fun setupTouchEventDocument(documentPath: String, withEventHandler: Boolean) {
+ setupDocument(documentPath + if (withEventHandler) "?event" else "")
+ }
+
+ private fun waitForScroll(timeout: Double) {
+ mainSession.evaluateJS("""
+ const targetWindow = document.querySelector('iframe') ?
+ document.querySelector('iframe').contentWindow : window;
+ new Promise((resolve, reject) => {
+ const start = Date.now();
+ function step() {
+ if (targetWindow.scrollY == targetWindow.scrollMaxY) {
+ resolve();
+ } else if ($timeout < (Date.now() - start)) {
+ reject();
+ } else {
+ window.requestAnimationFrame(step);
+ }
+ }
+ window.requestAnimationFrame(step);
+ });
+ """.trimIndent())
+ }
+
+ private fun testTouchEventForResult(withEventHandler: Boolean) {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+
+ // The content height is not greater than "screen height - the dynamic toolbar height".
+ setupTouchEventDocument(ROOT_100_PERCENT_HEIGHT_HTML_PATH, withEventHandler)
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ assertThat("The input result should be UNHANDLED in root_100_percent.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_UNHANDLED))
+
+ // There is a 100% height iframe which is not scrollable.
+ setupTouchEventDocument(IFRAME_100_PERCENT_HEIGHT_NO_SCROLLABLE_HTML_PATH, withEventHandler)
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ // The input result should NOT be handled in the iframe content,
+ // should NOT be handled in the root either.
+ assertThat("The input result should be UNHANDLED in iframe_100_percent_height_no_scrollable.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_UNHANDLED))
+
+ // There is a 100% height iframe which is scrollable.
+ setupTouchEventDocument(IFRAME_100_PERCENT_HEIGHT_SCROLLABLE_HTML_PATH, withEventHandler)
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ // The input result should be handled in the iframe content.
+ assertThat("The input result should be HANDLED_CONTENT in iframe_100_percent_height_scrollable.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT))
+
+ // Scroll to the bottom of the iframe
+ mainSession.evaluateJS("""
+ const iframe = document.querySelector('iframe');
+ iframe.contentWindow.scrollTo({
+ left: 0,
+ top: iframe.contentWindow.scrollMaxY,
+ behavior: 'instant'
+ });
+ """.trimIndent())
+ waitForScroll(scrollWaitTimeout)
+ mainSession.flushApzRepaints()
+
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ // The input result should still be handled in the iframe content.
+ assertThat("The input result should be HANDLED_CONTENT in iframe_100_percent_height_scrollable.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT))
+
+ // The content height is greater than "screen height - the dynamic toolbar height".
+ setupTouchEventDocument(ROOT_98VH_HTML_PATH, withEventHandler)
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ assertThat("The input result should be HANDLED in root_98vh.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+
+ // The content height is equal to "screen height".
+ setupTouchEventDocument(ROOT_100VH_HTML_PATH, withEventHandler)
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ assertThat("The input result should be HANDLED in root_100vh.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+
+ // There is a 98vh iframe which is not scrollable.
+ setupTouchEventDocument(IFRAME_98VH_NO_SCROLLABLE_HTML_PATH, withEventHandler)
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ // The input result should NOT be handled in the iframe content.
+ assertThat("The input result should be HANDLED in iframe_98vh_no_scrollable.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+
+ // There is a 98vh iframe which is scrollable.
+ setupTouchEventDocument(IFRAME_98VH_SCROLLABLE_HTML_PATH, withEventHandler)
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ // The input result should be handled in the iframe content initially.
+ assertThat("The input result should be HANDLED_CONTENT initially in iframe_98vh_scrollable.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT))
+
+ // Scroll to the bottom of the iframe
+ mainSession.evaluateJS("""
+ const iframe = document.querySelector('iframe');
+ iframe.contentWindow.scrollTo({
+ left: 0,
+ top: iframe.contentWindow.scrollMaxY,
+ behavior: 'instant'
+ });
+ """.trimIndent())
+ waitForScroll(scrollWaitTimeout)
+ mainSession.flushApzRepaints()
+
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ // Now the input result should be handled in the root APZC.
+ assertThat("The input result should be HANDLED in iframe_98vh_scrollable.html",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun touchEventForResultWithEventHandler() {
+ testTouchEventForResult(true)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun touchEventForResultWithoutEventHandler() {
+ testTouchEventForResult(false)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun touchEventForResultWithPreventDefault() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+
+ var files = arrayOf(
+ ROOT_100_PERCENT_HEIGHT_HTML_PATH,
+ ROOT_98VH_HTML_PATH,
+ ROOT_100VH_HTML_PATH,
+ IFRAME_100_PERCENT_HEIGHT_NO_SCROLLABLE_HTML_PATH,
+ IFRAME_100_PERCENT_HEIGHT_SCROLLABLE_HTML_PATH,
+ IFRAME_98VH_SCROLLABLE_HTML_PATH,
+ IFRAME_98VH_NO_SCROLLABLE_HTML_PATH)
+
+ for (file in files) {
+ setupDocument(file + "?event-prevent")
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ assertThat("The input result should be HANDLED_CONTENT in " + file,
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT))
+
+ // Scroll to the bottom edge if it's possible.
+ mainSession.evaluateJS("""
+ const targetWindow = document.querySelector('iframe') ?
+ document.querySelector('iframe').contentWindow : window;
+ targetWindow.scrollTo({
+ left: 0,
+ top: targetWindow.scrollMaxY,
+ behavior: 'instant'
+ });
+ """.trimIndent())
+ waitForScroll(scrollWaitTimeout)
+ mainSession.flushApzRepaints()
+
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ assertThat("The input result should be HANDLED_CONTENT in " + file,
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT))
+ }
+ }
+
+ private fun fling(): GeckoResult<Int> {
+ val downTime = SystemClock.uptimeMillis();
+ val down = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, 50f, 90f, 0)
+
+ val result = mainSession.panZoomController.onTouchEventForResult(down)
+ var move = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_MOVE, 50f, 70f, 0)
+ mainSession.panZoomController.onTouchEvent(move)
+ move = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_MOVE, 50f, 30f, 0)
+ mainSession.panZoomController.onTouchEvent(move)
+
+ val up = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 50f, 10f, 0)
+ mainSession.panZoomController.onTouchEvent(up)
+ return result
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun dontCrashDuringFastFling() {
+ setupDocument(TOUCHSTART_HTML_PATH)
+
+ fling()
+ fling()
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun inputResultForFastFling() {
+ // Bug 1687842.
+ assumeTrue(false)
+
+ setupDocument(TOUCHSTART_HTML_PATH)
+
+ var value = sessionRule.waitForResult(fling())
+ assertThat("The initial input result should be HANDLED",
+ value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+ // Trigger the next fling during the initial scrolling.
+ value = sessionRule.waitForResult(fling())
+ assertThat("The input result should be IGNORED during the fast fling",
+ value, equalTo(PanZoomController.INPUT_RESULT_IGNORED))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt
new file mode 100644
index 0000000000..a6d77b5787
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt
@@ -0,0 +1,336 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException
+import org.mozilla.geckoview.test.util.Callbacks
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.hamcrest.Matchers.*
+import org.json.JSONArray
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.Ignore
+import org.mozilla.geckoview.GeckoRuntimeSettings
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PermissionDelegateTest : BaseSessionTest() {
+
+ private fun hasPermission(permission: String): Boolean {
+ if (Build.VERSION.SDK_INT < 23) {
+ return true
+ }
+ return PackageManager.PERMISSION_GRANTED ==
+ InstrumentationRegistry.getInstrumentation().targetContext.checkSelfPermission(permission)
+ }
+
+ private fun isEmulator(): Boolean {
+ return "generic".equals(Build.DEVICE) || Build.DEVICE.startsWith("generic_")
+ }
+
+ @Test fun media() {
+ assertInAutomationThat("Should have camera permission",
+ hasPermission(Manifest.permission.CAMERA), equalTo(true))
+
+ assertInAutomationThat("Should have microphone permission",
+ hasPermission(Manifest.permission.RECORD_AUDIO),
+ equalTo(true))
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val devices = mainSession.evaluateJS(
+ "window.navigator.mediaDevices.enumerateDevices()") as JSONArray
+
+ var hasVideo = false
+ var hasAudio = false
+ for (i in 0 until devices.length()) {
+ if (devices.getJSONObject(i).getString("kind") == "videoinput") {
+ hasVideo = true;
+ }
+ if (devices.getJSONObject(i).getString("kind") == "audioinput") {
+ hasAudio = true;
+ }
+ }
+
+ assertThat("Device list should contain camera device",
+ hasVideo, equalTo(true))
+ assertThat("Device list should contain microphone device",
+ hasAudio, equalTo(true))
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onMediaPermissionRequest(
+ session: GeckoSession, uri: String,
+ video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ callback: GeckoSession.PermissionDelegate.MediaCallback) {
+ assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
+ assertThat("Video source should be valid", video, not(emptyArray()))
+
+ if (isEmulator()) {
+ callback.grant(video!![0], null)
+ } else {
+ assertThat("Audio source should be valid", audio, not(emptyArray()))
+ callback.grant(video!![0], audio!![0])
+ }
+ }
+ })
+
+ // Start a video stream, with audio if on a real device.
+ var code: String?
+ if (isEmulator()) {
+ code = """this.stream = window.navigator.mediaDevices.getUserMedia({
+ video: { width: 320, height: 240, frameRate: 10 },
+ });"""
+ } else {
+ code = """this.stream = window.navigator.mediaDevices.getUserMedia({
+ video: { width: 320, height: 240, frameRate: 10 },
+ audio: true
+ });"""
+ }
+
+ // Stop the stream and check active flag and id
+ val isActive = mainSession.waitForJS(
+ """$code
+ this.stream.then(stream => {
+ if (!stream.active || stream.id == '') {
+ return false;
+ }
+
+ stream.getTracks().forEach(track => track.stop());
+ return true;
+ })
+ """.trimMargin()) as Boolean
+
+ assertThat("Stream should be active and id should not be empty.", isActive, equalTo(true));
+
+ // Now test rejecting the request.
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onMediaPermissionRequest(
+ session: GeckoSession, uri: String,
+ video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ callback: GeckoSession.PermissionDelegate.MediaCallback) {
+ callback.reject()
+ }
+ })
+
+ try {
+ if (isEmulator()) {
+ mainSession.waitForJS("""
+ window.navigator.mediaDevices.getUserMedia({ video: true })""")
+ } else {
+ mainSession.waitForJS("""
+ window.navigator.mediaDevices.getUserMedia({ audio: true: video: true })""")
+ }
+ fail("Request should have failed")
+ } catch (e: RejectedPromiseException) {
+ assertThat("Error should be correct",
+ e.reason as String, containsString("NotAllowedError"))
+ }
+ }
+
+ @Test fun geolocation() {
+ assertInAutomationThat("Should have location permission",
+ hasPermission(Manifest.permission.ACCESS_FINE_LOCATION),
+ equalTo(true))
+
+ val url = "https://example.com/"
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ // Ensure the content permission is asked first, before the Android permission.
+ @AssertCalled(count = 1, order = [1])
+ override fun onContentPermissionRequest(
+ session: GeckoSession, uri: String?, type: Int,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ assertThat("URI should match", uri, endsWith(url))
+ assertThat("Type should match", type,
+ equalTo(GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION))
+ callback.grant()
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession, permissions: Array<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ assertThat("Permissions list should be correct",
+ listOf(*permissions!!), hasItems(Manifest.permission.ACCESS_FINE_LOCATION))
+ callback.grant()
+ }
+ })
+
+ try {
+ val hasPosition = mainSession.waitForJS("""new Promise((resolve, reject) =>
+ window.navigator.geolocation.getCurrentPosition(
+ position => resolve(
+ position.coords.latitude !== undefined &&
+ position.coords.longitude !== undefined),
+ error => reject(error.code)))""") as Boolean
+
+ assertThat("Request should succeed", hasPosition, equalTo(true))
+ } catch (ex: RejectedPromiseException) {
+ assertThat("Error should not because the permission was denied.",
+ ex.reason as String, not("1"))
+ }
+ }
+
+ @Test fun geolocation_reject() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession, uri: String?, type: Int,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ callback.reject()
+ }
+
+ @AssertCalled(count = 0)
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession, permissions: Array<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ }
+ })
+
+ val errorCode = mainSession.waitForJS("""new Promise((resolve, reject) =>
+ window.navigator.geolocation.getCurrentPosition(reject,
+ error => resolve(error.code)
+ ))""")
+
+ // Error code 1 means permission denied.
+ assertThat("Request should fail", errorCode as Double, equalTo(1.0))
+ }
+
+ @Test fun notification() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession, uri: String?, type: Int,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
+ assertThat("Type should match", type,
+ equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+ callback.grant()
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat("Permission should be granted",
+ result as String, equalTo("granted"))
+ }
+
+ @Ignore("disable test for frequently failing Bug 1542525")
+ @Test fun notification_reject() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession, uri: String?, type: Int,
+ callback: GeckoSession.PermissionDelegate.Callback) {
+ callback.reject()
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat("Permission should not be granted",
+ result as String, equalTo("denied"))
+ }
+
+ @Test
+ fun autoplayReject() {
+ // The profile used in automation sets this to false, so we need to hack it back to true here.
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "media.geckoview.autoplay.request" to true))
+
+ mainSession.loadTestPath(AUTOPLAY_PATH)
+
+ mainSession.waitUntilCalled(object : Callbacks.PermissionDelegate {
+ @AssertCalled(count = 2)
+ override fun onContentPermissionRequest(session: GeckoSession, uri: String?, type: Int, callback: GeckoSession.PermissionDelegate.Callback) {
+ val expectedType = if (sessionRule.currentCall.counter == 1) GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE else GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE
+ assertThat("Type should match", type, equalTo(expectedType))
+ callback.reject()
+ }
+ })
+ }
+
+ // @Test fun persistentStorage() {
+ // mainSession.loadTestPath(HELLO_HTML_PATH)
+ // mainSession.waitForPageStop()
+
+ // // Persistent storage can be rejected
+ // mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ // @AssertCalled(count = 1)
+ // override fun onContentPermissionRequest(
+ // session: GeckoSession, uri: String?, type: Int,
+ // callback: GeckoSession.PermissionDelegate.Callback) {
+ // callback.reject()
+ // }
+ // })
+
+ // var success = mainSession.waitForJS("""window.navigator.storage.persist()""")
+
+ // assertThat("Request should fail",
+ // success as Boolean, equalTo(false))
+
+ // // Persistent storage can be granted
+ // mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ // // Ensure the content permission is asked first, before the Android permission.
+ // @AssertCalled(count = 1, order = [1])
+ // override fun onContentPermissionRequest(
+ // session: GeckoSession, uri: String?, type: Int,
+ // callback: GeckoSession.PermissionDelegate.Callback) {
+ // assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
+ // assertThat("Type should match", type,
+ // equalTo(GeckoSession.PermissionDelegate.PERMISSION_PERSISTENT_STORAGE))
+ // callback.grant()
+ // }
+ // })
+
+ // success = mainSession.waitForJS("""window.navigator.storage.persist()""")
+
+ // assertThat("Request should succeed",
+ // success as Boolean,
+ // equalTo(true))
+
+ // // after permission granted further requests will always return true, regardless of response
+ // mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+ // @AssertCalled(count = 1)
+ // override fun onContentPermissionRequest(
+ // session: GeckoSession, uri: String?, type: Int,
+ // callback: GeckoSession.PermissionDelegate.Callback) {
+ // callback.reject()
+ // }
+ // })
+
+ // success = mainSession.waitForJS("""window.navigator.storage.persist()""")
+
+ // assertThat("Request should succeed",
+ // success as Boolean, equalTo(true))
+ // }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt
new file mode 100644
index 0000000000..bef092693c
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt
@@ -0,0 +1,84 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoSessionSettings
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PrivateModeTest : BaseSessionTest() {
+ @Test
+ fun privateDataNotShared() {
+ sessionRule.session.loadUri("https://example.com")
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("""
+ localStorage.setItem('ctx', 'regular');
+ """)
+
+ val privateSession = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .usePrivateMode(true)
+ .build())
+ privateSession.loadUri("https://example.com")
+ privateSession.waitForPageStop()
+ var localStorage = privateSession.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ // Ensure that the regular session's data hasn't leaked into the private session.
+ assertThat("Private mode local storage value should be empty",
+ localStorage,
+ Matchers.equalTo("null"))
+
+ privateSession.evaluateJS("""
+ localStorage.setItem('ctx', 'private');
+ """)
+
+ localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ // Conversely, ensure private data hasn't leaked into the regular session.
+ assertThat("Regular mode storage value should be unchanged",
+ localStorage,
+ Matchers.equalTo("regular"))
+ }
+
+ @Test
+ fun privateModeStorageShared() {
+ // Two private mode sessions should share the same storage (bug 1533406).
+ val privateSession1 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .usePrivateMode(true)
+ .build())
+ privateSession1.loadUri("https://example.com")
+ privateSession1.waitForPageStop()
+
+ privateSession1.evaluateJS("""
+ localStorage.setItem('ctx', 'private');
+ """)
+
+ val privateSession2 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .usePrivateMode(true)
+ .build())
+ privateSession2.loadUri("https://example.com")
+ privateSession2.waitForPageStop()
+
+ val localStorage = privateSession2.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Private mode storage value still set",
+ localStorage,
+ Matchers.equalTo("private"))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt
new file mode 100644
index 0000000000..0131a22c8f
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt
@@ -0,0 +1,504 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.util.Base64
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.*
+import org.junit.Assume.assumeThat
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.*
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ProgressDelegateTest : BaseSessionTest() {
+
+ fun testProgress(path: String) {
+ sessionRule.session.loadTestPath(path)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+ var lastProgress = -1
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate,
+ Callbacks.NavigationDelegate {
+ @AssertCalled
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("LocationChange is called", url, endsWith(path))
+ }
+ @AssertCalled
+ override fun onProgressChange(session: GeckoSession, progress: Int) {
+ assertThat("Progress must be strictly increasing", progress,
+ greaterThan(lastProgress))
+ lastProgress = progress
+ counter++
+ }
+ @AssertCalled
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("PageStart is called", url, endsWith(path))
+ }
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("PageStop is called", success, equalTo(true))
+ }
+ })
+
+ assertThat("Callback should be called at least twice", counter,
+ greaterThanOrEqualTo(2))
+ assertThat("Last progress value should be 100", lastProgress,
+ equalTo(100))
+ }
+
+ @Test fun loadProgress() {
+ testProgress(HELLO_HTML_PATH)
+ // Test that loading the same path again still
+ // results in the right progress events
+ testProgress(HELLO_HTML_PATH)
+ // Test that calling a different path works too
+ testProgress(HELLO2_HTML_PATH)
+ }
+
+
+ @Test fun load() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URL should not be null", url, notNullValue())
+ assertThat("URL should match", url, endsWith(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Security info should not be null", securityInfo, notNullValue())
+
+ assertThat("Should not be secure", securityInfo.isSecure, equalTo(false))
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("Load should succeed", success, equalTo(true))
+ }
+ })
+ }
+
+ @Ignore
+ @Test fun multipleLoads() {
+ sessionRule.session.loadUri(UNKNOWN_HOST_URI)
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2, order = [1, 3])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URL should match", url,
+ endsWith(forEachCall(UNKNOWN_HOST_URI, HELLO_HTML_PATH)))
+ }
+
+ @AssertCalled(count = 2, order = [2, 4])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ // The first load is certain to fail because of interruption by the second load
+ // or by invalid domain name, whereas the second load is certain to succeed.
+ assertThat("Success flag should match", success,
+ equalTo(forEachCall(false, true)))
+ };
+ })
+ }
+
+ @Test fun reload() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.reload()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URL should match", url, endsWith(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should succeed", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test fun goBackAndForward() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ sessionRule.session.loadTestPath(HELLO2_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.goBack()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URL should match", url, endsWith(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should succeed", success, equalTo(true))
+ }
+ })
+
+ sessionRule.session.goForward()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should succeed", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test fun correctSecurityInfoForValidTLS_automation() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(true))
+
+ sessionRule.session.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ assertThat("Should be secure",
+ securityInfo.isSecure, equalTo(true))
+ assertThat("Should not be exception",
+ securityInfo.isException, equalTo(false))
+ assertThat("Origin should match",
+ securityInfo.origin,
+ equalTo("https://example.com"))
+ assertThat("Host should match",
+ securityInfo.host,
+ equalTo("example.com"))
+ assertThat("Subject should match",
+ securityInfo.certificate?.subjectX500Principal?.name,
+ equalTo("CN=example.com"))
+ assertThat("Issuer should match",
+ securityInfo.certificate?.issuerX500Principal?.name,
+ equalTo("OU=Profile Guided Optimization,O=Mozilla Testing,CN=Temporary Certificate Authority"))
+ assertThat("Security mode should match",
+ securityInfo.securityMode,
+ equalTo(GeckoSession.ProgressDelegate.SecurityInformation.SECURITY_MODE_IDENTIFIED))
+ assertThat("Active mixed mode should match",
+ securityInfo.mixedModeActive,
+ equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN))
+ assertThat("Passive mixed mode should match",
+ securityInfo.mixedModePassive,
+ equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN))
+ }
+ })
+ }
+
+ @LargeTest
+ @Test fun correctSecurityInfoForValidTLS_local() {
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+
+ sessionRule.session.loadUri("https://mozilla-modern.badssl.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ assertThat("Should be secure",
+ securityInfo.isSecure, equalTo(true))
+ assertThat("Should not be exception",
+ securityInfo.isException, equalTo(false))
+ assertThat("Origin should match",
+ securityInfo.origin,
+ equalTo("https://mozilla-modern.badssl.com"))
+ assertThat("Host should match",
+ securityInfo.host,
+ equalTo("mozilla-modern.badssl.com"))
+ assertThat("Subject should match",
+ securityInfo.certificate?.subjectX500Principal?.name,
+ equalTo("CN=*.badssl.com,O=Lucas Garron,L=Walnut Creek,ST=California,C=US"))
+ assertThat("Issuer should match",
+ securityInfo.certificate?.issuerX500Principal?.name,
+ equalTo("CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US"))
+ assertThat("Security mode should match",
+ securityInfo.securityMode,
+ equalTo(GeckoSession.ProgressDelegate.SecurityInformation.SECURITY_MODE_IDENTIFIED))
+ assertThat("Active mixed mode should match",
+ securityInfo.mixedModeActive,
+ equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN))
+ assertThat("Passive mixed mode should match",
+ securityInfo.mixedModePassive,
+ equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN))
+ }
+ })
+ }
+
+ @LargeTest
+ @Test fun noSecurityInfoForExpiredTLS() {
+ sessionRule.session.loadUri(if (sessionRule.env.isAutomation)
+ "https://expired.example.com"
+ else
+ "https://expired.badssl.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should fail", success, equalTo(false))
+ }
+
+ @AssertCalled(false)
+ override fun onSecurityChange(session: GeckoSession,
+ securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
+ }
+ })
+ }
+
+ val errorEpsilon = 0.1
+
+ private fun waitForScroll(offset: Double, timeout: Double, param: String) {
+ mainSession.evaluateJS("""
+ new Promise((resolve, reject) => {
+ const start = Date.now();
+ function step() {
+ if (window.visualViewport.$param >= ($offset - $errorEpsilon)) {
+ resolve();
+ } else if ($timeout < (Date.now() - start)) {
+ reject();
+ } else {
+ window.requestAnimationFrame(step);
+ }
+ }
+ window.requestAnimationFrame(step);
+ });
+ """.trimIndent())
+ }
+
+ private fun waitForVerticalScroll(offset: Double, timeout: Double) {
+ waitForScroll(offset, timeout, "pageTop")
+ }
+
+ fun collectState(vararg uris: String) : GeckoSession.SessionState {
+ for (uri in uris) {
+ mainSession.loadUri(uri)
+ sessionRule.waitForPageStop()
+ }
+
+ mainSession.evaluateJS("document.querySelector('#name').value = 'the name';")
+ mainSession.evaluateJS("document.querySelector('#name').dispatchEvent(new Event('input'));")
+
+ mainSession.evaluateJS("window.scrollBy(0, 100);")
+ waitForVerticalScroll(100.0, sessionRule.env.defaultTimeoutMillis.toDouble())
+
+ var savedState : GeckoSession.SessionState? = null
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count=1)
+ override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+ savedState = state
+
+ val serialized = state.toString()
+ val deserialized = GeckoSession.SessionState.fromString(serialized)
+ assertThat("Deserialized session state should match", deserialized, equalTo(state))
+ }
+ })
+
+ assertThat("State should not be null", savedState, notNullValue())
+ return savedState!!
+ }
+
+ @WithDisplay(width = 400, height = 400)
+ @Test fun saveAndRestoreStateNewSession() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val helloUri = createTestUrl(HELLO_HTML_PATH)
+ val startUri = createTestUrl(SAVE_STATE_PATH)
+
+ val savedState = collectState(helloUri, startUri);
+
+ val session = sessionRule.createOpenSession()
+ session.addDisplay(400, 400)
+
+ session.restoreState(savedState)
+ session.waitForPageStop()
+
+ session.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URI should match", url, equalTo(startUri))
+ }
+ })
+
+ /* TODO: Reenable when we have a workaround for ContentSessionStore not
+ saving in response to JS-driven formdata changes.
+ assertThat("'name' field should match",
+ mainSession.evaluateJS("$('#name').value").toString(),
+ equalTo("the name"))*/
+
+ assertThat("Scroll position should match",
+ session.evaluateJS("window.visualViewport.pageTop") as Double,
+ closeTo(100.0, .5))
+
+ session.goBack()
+
+ session.waitUntilCalled(object: Callbacks.NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("History should be preserved", url, equalTo(helloUri))
+ }
+ })
+ }
+
+ @WithDisplay(width = 400, height = 400)
+ @Test fun saveAndRestoreState() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val startUri = createTestUrl(SAVE_STATE_PATH)
+ val savedState = collectState(startUri);
+
+ mainSession.loadUri("about:blank")
+ sessionRule.waitForPageStop()
+
+ mainSession.restoreState(savedState)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+ @AssertCalled
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("URI should match", url, equalTo(startUri))
+ }
+ })
+
+ /* TODO: Reenable when we have a workaround for ContentSessionStore not
+ saving in response to JS-driven formdata changes.
+ assertThat("'name' field should match",
+ mainSession.evaluateJS("$('#name').value").toString(),
+ equalTo("the name"))*/
+
+ assertThat("Scroll position should match",
+ mainSession.evaluateJS("window.visualViewport.pageTop") as Double,
+ closeTo(100.0, .5))
+ }
+
+ @WithDisplay(width = 400, height = 400)
+ @Test fun flushSessionState() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val startUri = createTestUrl(SAVE_STATE_PATH)
+ mainSession.loadUri(startUri)
+ sessionRule.waitForPageStop()
+
+ var oldState : GeckoSession.SessionState? = null
+
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
+ oldState = sessionState
+ }
+ })
+
+ assertThat("State should not be null", oldState, notNullValue())
+
+ mainSession.setActive(false)
+
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
+ assertThat("Old session state and new should match", sessionState, equalTo(oldState))
+ }
+ })
+ }
+
+ @NullDelegate(GeckoSession.HistoryDelegate::class)
+ @Test fun noHistoryDelegateOnSessionStateChange() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
+ }
+ })
+ }
+
+ private fun createDataUri(bytes: ByteArray,
+ mimeType: String?): String {
+ return String.format("data:%s;base64,%s", mimeType ?: "",
+ Base64.encodeToString(bytes, Base64.NO_WRAP))
+ }
+
+ @Test(expected = UiThreadUtils.TimeoutException::class)
+ fun handlingLargeDataURIs() {
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+ });
+
+ val dataBytes = ByteArray(3 * 1024 * 1024)
+ val uri = createDataUri(dataBytes, "*/*")
+
+ sessionRule.session.loadTestPath(DATA_URI_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("document.querySelector('#largeLink').href = \"$uri\"")
+ sessionRule.session.evaluateJS("document.querySelector('#largeLink').click()")
+ sessionRule.session.waitForPageStop()
+ }
+
+ @Test fun handlingSmallDataURIs() {
+ sessionRule.delegateUntilTestEnd(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 2)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+ });
+
+ val dataBytes = this.getTestBytes("/assets/www/images/test.gif")
+ val uri = createDataUri(dataBytes, "image/*")
+
+ sessionRule.session.loadTestPath(DATA_URI_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("document.querySelector('#smallLink').href = \"$uri\"")
+ sessionRule.session.evaluateJS("document.querySelector('#smallLink').click()")
+ sessionRule.session.waitForPageStop()
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt
new file mode 100644
index 0000000000..45d988fc72
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt
@@ -0,0 +1,583 @@
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.AllowOrDeny
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.GeckoSession.PromptDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.util.Callbacks
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Assert
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PromptDelegateTest : BaseSessionTest() {
+ @Test fun popupTestAllow() {
+ // Ensure popup blocking is enabled for this test.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to true))
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate, Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onPopupPrompt(session: GeckoSession, prompt: PromptDelegate.PopupPrompt)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URL should not be null", prompt.targetUri, notNullValue())
+ assertThat("URL should match", prompt.targetUri, endsWith(HELLO_HTML_PATH))
+ return GeckoResult.fromValue(prompt.confirm(AllowOrDeny.ALLOW))
+ }
+
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URL should not be null", request.uri, notNullValue())
+ assertThat("URL should match", request.uri, endsWith(forEachCall(POPUP_HTML_PATH, HELLO_HTML_PATH)))
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ assertThat("URL should not be null", uri, notNullValue())
+ assertThat("URL should match", uri, endsWith(HELLO_HTML_PATH))
+ return null
+ }
+ })
+
+ sessionRule.session.loadTestPath(POPUP_HTML_PATH)
+ sessionRule.waitUntilCalled(Callbacks.NavigationDelegate::class, "onNewSession")
+ }
+
+ @Test fun popupTestBlock() {
+ // Ensure popup blocking is enabled for this test.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to true))
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate, Callbacks.NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onPopupPrompt(session: GeckoSession, prompt: PromptDelegate.PopupPrompt)
+ : GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URL should not be null", prompt.targetUri, notNullValue())
+ assertThat("URL should match", prompt.targetUri, endsWith(HELLO_HTML_PATH))
+ return GeckoResult.fromValue(prompt.confirm(AllowOrDeny.DENY))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLoadRequest(session: GeckoSession,
+ request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ assertThat("Session should not be null", session, notNullValue())
+ assertThat("URL should not be null", request.uri, notNullValue())
+ assertThat("URL should match", request.uri, endsWith(POPUP_HTML_PATH))
+ return null
+ }
+
+ @AssertCalled(count = 0)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ return null
+ }
+ })
+
+ sessionRule.session.loadTestPath(POPUP_HTML_PATH)
+ sessionRule.waitForPageStop()
+ sessionRule.session.waitForRoundTrip()
+ }
+
+ @Ignore // TODO: Reenable when 1501574 is fixed.
+ @Test fun alertTest() {
+ sessionRule.session.evaluateJS("alert('Alert!');")
+
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Message should match", "Alert!", equalTo(prompt.message))
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @Test fun dismissAuthTest() {
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onAuthPrompt(session: GeckoSession, prompt: PromptDelegate.AuthPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ //TODO: Figure out some better testing here.
+ return null
+ }
+ })
+
+ mainSession.loadTestPath("/basic-auth/foo/bar")
+ mainSession.waitForPageStop()
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @Test fun buttonTest() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onButtonPrompt(session: GeckoSession, prompt: PromptDelegate.ButtonPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Message should match", "Confirm?", equalTo(prompt.message))
+ return GeckoResult.fromValue(prompt.confirm(PromptDelegate.ButtonPrompt.Type.POSITIVE))
+ }
+ })
+
+ assertThat("Result should match",
+ sessionRule.session.waitForJS("confirm('Confirm?')") as Boolean,
+ equalTo(true))
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onButtonPrompt(session: GeckoSession, prompt: PromptDelegate.ButtonPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Message should match", "Confirm?", equalTo(prompt.message))
+ return GeckoResult.fromValue(prompt.confirm(PromptDelegate.ButtonPrompt.Type.NEGATIVE))
+ }
+ })
+
+ assertThat("Result should match",
+ sessionRule.session.waitForJS("confirm('Confirm?')") as Boolean,
+ equalTo(false))
+ }
+
+ @Test
+ fun onFormResubmissionPrompt() {
+ sessionRule.session.loadTestPath(RESUBMIT_CONFIRM)
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.evaluateJS(
+ "document.querySelector('#text').value = 'Some text';" +
+ "document.querySelector('#submit').click();"
+ )
+
+ // Submitting the form causes a navigation
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<Void>()
+ sessionRule.delegateUntilTestEnd(object: Callbacks.ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("Only HELLO_HTML_PATH should load", url, endsWith(HELLO_HTML_PATH))
+ result.complete(null)
+ }
+ })
+
+ val promptResult = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptResult2 = GeckoResult<PromptDelegate.PromptResponse>()
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onRepostConfirmPrompt(session: GeckoSession, prompt: PromptDelegate.RepostConfirmPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ // We have to return something here because otherwise the delegate will be invoked
+ // before we have a chance to override it in the waitUntilCalled call below
+ return forEachCall(promptResult, promptResult2)
+ }
+ })
+
+ // This should trigger a confirm resubmit prompt
+ sessionRule.session.reload();
+
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onRepostConfirmPrompt(session: GeckoSession, prompt: PromptDelegate.RepostConfirmPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ promptResult.complete(prompt.confirm(AllowOrDeny.DENY))
+ return promptResult
+ }
+ })
+
+ sessionRule.waitForResult(promptResult)
+
+ // Trigger it again, this time the load should go through
+ sessionRule.session.reload();
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onRepostConfirmPrompt(session: GeckoSession, prompt: PromptDelegate.RepostConfirmPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ promptResult2.complete(prompt.confirm(AllowOrDeny.ALLOW))
+ return promptResult2
+ }
+ })
+
+ sessionRule.waitForResult(promptResult2)
+ sessionRule.waitForResult(result)
+ }
+
+ @Test
+ fun onBeforeUnloadTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "dom.require_user_interaction_for_beforeunload" to false
+ ))
+ sessionRule.session.loadTestPath(BEFORE_UNLOAD)
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<Void>()
+ sessionRule.delegateUntilTestEnd(object: Callbacks.ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat("Only HELLO2_HTML_PATH should load", url, endsWith(HELLO2_HTML_PATH))
+ result.complete(null)
+ }
+ })
+
+ val promptResult = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptResult2 = GeckoResult<PromptDelegate.PromptResponse>()
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onBeforeUnloadPrompt(session: GeckoSession, prompt: PromptDelegate.BeforeUnloadPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ // We have to return something here because otherwise the delegate will be invoked
+ // before we have a chance to override it in the waitUntilCalled call below
+ return forEachCall(promptResult, promptResult2)
+ }
+ })
+
+ // This will try to load "hello.html" but will be denied, if the request
+ // goes through anyway the onLoadRequest delegate above will throw an exception
+ sessionRule.session.evaluateJS("document.querySelector('#navigateAway').click()")
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onBeforeUnloadPrompt(session: GeckoSession, prompt: PromptDelegate.BeforeUnloadPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ promptResult.complete(prompt.confirm(AllowOrDeny.DENY))
+ return promptResult
+ }
+ })
+
+ sessionRule.waitForResult(promptResult)
+
+ // This request will go through and end the test. Doing the negative case first will
+ // ensure that if either of this tests fail the test will fail.
+ sessionRule.session.evaluateJS("document.querySelector('#navigateAway2').click()")
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onBeforeUnloadPrompt(session: GeckoSession, prompt: PromptDelegate.BeforeUnloadPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ promptResult2.complete(prompt.confirm(AllowOrDeny.ALLOW))
+ return promptResult2
+ }
+ })
+
+ sessionRule.waitForResult(promptResult2)
+ sessionRule.waitForResult(result)
+ }
+
+ @Test fun textTest() {
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.delegateUntilTestEnd(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onTextPrompt(session: GeckoSession, prompt: PromptDelegate.TextPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Message should match", "Prompt:", equalTo(prompt.message))
+ assertThat("Default should match", "default", equalTo(prompt.defaultValue))
+ return GeckoResult.fromValue(prompt.confirm("foo"))
+ }
+ })
+
+ assertThat("Result should match",
+ sessionRule.session.waitForJS("prompt('Prompt:', 'default')") as String,
+ equalTo("foo"))
+ }
+
+ @Ignore // TODO: Figure out weird test env behavior here.
+ @Test fun choiceTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(PROMPT_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("document.getElementById('selectexample').click();")
+
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @Test fun colorTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(PROMPT_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onColorPrompt(session: GeckoSession, prompt: PromptDelegate.ColorPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Value should match", "#ffffff", equalTo(prompt.defaultValue))
+ return GeckoResult.fromValue(prompt.confirm("#123456"))
+ }
+ })
+
+ sessionRule.session.evaluateJS("""
+ this.c = document.getElementById('colorexample');
+ """.trimIndent())
+
+ val promise = sessionRule.session.evaluatePromiseJS("""
+ new Promise((resolve, reject) => {
+ this.c.addEventListener(
+ 'change',
+ event => resolve(event.target.value),
+ false
+ );
+ })""".trimIndent())
+
+ sessionRule.session.evaluateJS("this.c.click();")
+
+ assertThat("Value should match",
+ promise.value as String,
+ equalTo("#123456"))
+ }
+
+ @Ignore // TODO: Figure out weird test env behavior here.
+ @Test fun dateTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(PROMPT_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("document.getElementById('dateexample').click();")
+
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @Test fun fileTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ sessionRule.session.loadTestPath(PROMPT_HTML_PATH)
+ sessionRule.session.waitForPageStop()
+
+ sessionRule.session.evaluateJS("document.getElementById('fileexample').click();")
+
+ sessionRule.waitUntilCalled(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onFilePrompt(session: GeckoSession, prompt: PromptDelegate.FilePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Length of mimeTypes should match", 2, equalTo(prompt.mimeTypes!!.size))
+ assertThat("First accept attribute should match", "image/*", equalTo(prompt.mimeTypes?.get(0)))
+ assertThat("Second accept attribute should match", ".pdf", equalTo(prompt.mimeTypes?.get(1)))
+ assertThat("Capture attribute should match", PromptDelegate.FilePrompt.Capture.USER, equalTo(prompt.capture))
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @Test fun shareTextSucceeds() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val shareText = "Example share text"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Text field is not null", prompt.text, notNullValue())
+ assertThat("Title field is null", prompt.title, nullValue())
+ assertThat("Url field is null", prompt.uri, nullValue())
+ assertThat("Text field contains correct value", prompt.text, equalTo(shareText))
+ return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.SUCCESS))
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({text: "${shareText}"})""")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ Assert.fail("Share must succeed." + e.reason as String)
+ }
+ }
+
+ @Test fun shareUrlSucceeds() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val shareUrl = "https://example.com/"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Text field is null", prompt.text, nullValue())
+ assertThat("Title field is null", prompt.title, nullValue())
+ assertThat("Url field is not null", prompt.uri, notNullValue())
+ assertThat("Text field contains correct value", prompt.uri, equalTo(shareUrl))
+ return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.SUCCESS))
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({url: "${shareUrl}"})""")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ Assert.fail("Share must succeed." + e.reason as String)
+ }
+ }
+
+ @Test fun shareTitleSucceeds() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val shareTitle = "Title!"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Text field is null", prompt.text, nullValue())
+ assertThat("Title field is not null", prompt.title, notNullValue())
+ assertThat("Url field is null", prompt.uri, nullValue())
+ assertThat("Text field contains correct value", prompt.title, equalTo(shareTitle))
+ return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.SUCCESS))
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({title: "${shareTitle}"})""")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ Assert.fail("Share must succeed." + e.reason as String)
+ }
+ }
+
+ @Test fun failedShareReturnsDataError() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val shareUrl = "https://www.example.com"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.FAILURE))
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({url: "${shareUrl}"})""")
+ Assert.fail("Request should have failed")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ assertThat("Error should be correct",
+ e.reason as String, containsString("DataError"))
+ }
+ }
+
+ @Test fun abortedShareReturnsAbortError() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val shareUrl = "https://www.example.com"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.ABORT))
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({url: "${shareUrl}"})""")
+ Assert.fail("Request should have failed")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ assertThat("Error should be correct",
+ e.reason as String, containsString("AbortError"))
+ }
+ }
+
+ @Test fun dismissedShareReturnsAbortError() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val shareUrl = "https://www.example.com"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({url: "${shareUrl}"})""")
+ Assert.fail("Request should have failed")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ assertThat("Error should be correct",
+ e.reason as String, containsString("AbortError"))
+ }
+ }
+
+ @Test fun emptyShareReturnsTypeError() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 0)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({})""")
+ Assert.fail("Request should have failed")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ assertThat("Error should be correct",
+ e.reason as String, containsString("TypeError"))
+ }
+ }
+
+ @Test fun invalidShareUrlReturnsTypeError() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // Invalid port should cause URL parser to fail.
+ val shareUrl = "http://www.example.com:123456"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 0)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({url: "${shareUrl}"})""")
+ Assert.fail("Request should have failed")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ assertThat("Error should be correct",
+ e.reason as String, containsString("TypeError"))
+ }
+ }
+
+ @Test fun shareRequiresUserInteraction() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to true))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val shareUrl = "https://www.example.com"
+
+ sessionRule.delegateDuringNextWait(object : Callbacks.PromptDelegate {
+ @AssertCalled(count = 0)
+ override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ try {
+ mainSession.waitForJS("""window.navigator.share({url: "${shareUrl}"})""")
+ Assert.fail("Request should have failed")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ assertThat("Error should be correct",
+ e.reason as String, containsString("NotAllowedError"))
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt
new file mode 100644
index 0000000000..928e3b5f5e
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt
@@ -0,0 +1,182 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.provider.Settings
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Log
+import org.hamcrest.Matchers.*
+import org.junit.Assume.assumeThat
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import kotlin.math.roundToInt
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.WebRequestError
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.util.Callbacks
+import java.util.concurrent.atomic.AtomicBoolean
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class RuntimeSettingsTest : BaseSessionTest() {
+
+ @Ignore("disable test for frequently failing Bug 1538430")
+ @Test fun automaticFontSize() {
+ val settings = sessionRule.runtime.settings
+ var initialFontSize = 2.15f
+ var initialFontInflation = true
+ settings.fontSizeFactor = initialFontSize
+ assertThat("initial font scale $initialFontSize set",
+ settings.fontSizeFactor.toDouble(), closeTo(initialFontSize.toDouble(), 0.05))
+ settings.fontInflationEnabled = initialFontInflation
+ assertThat("font inflation initially set to $initialFontInflation",
+ settings.fontInflationEnabled, `is`(initialFontInflation))
+
+
+ settings.automaticFontSizeAdjustment = true
+ val contentResolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver
+ val expectedFontSizeFactor = Settings.System.getFloat(contentResolver,
+ Settings.System.FONT_SCALE, 1.0f)
+ assertThat("Gecko font scale should match system font scale",
+ settings.fontSizeFactor.toDouble(), closeTo(expectedFontSizeFactor.toDouble(), 0.05))
+ assertThat("font inflation enabled",
+ settings.fontInflationEnabled, `is`(initialFontInflation))
+
+ settings.automaticFontSizeAdjustment = false
+ assertThat("Gecko font scale restored to previous value",
+ settings.fontSizeFactor.toDouble(), closeTo(initialFontSize.toDouble(), 0.05))
+ assertThat("font inflation restored to previous value",
+ settings.fontInflationEnabled, `is`(initialFontInflation))
+
+ // Now check with that with font inflation initially off, the initial state is still
+ // restored correctly after switching auto mode back off.
+ // Also reset font size factor back to its default value of 1.0f.
+ initialFontSize = 1.0f
+ initialFontInflation = false
+ settings.fontSizeFactor = initialFontSize
+ assertThat("initial font scale $initialFontSize set",
+ settings.fontSizeFactor.toDouble(), closeTo(initialFontSize.toDouble(), 0.05))
+ settings.fontInflationEnabled = initialFontInflation
+ assertThat("font inflation initially set to $initialFontInflation",
+ settings.fontInflationEnabled, `is`(initialFontInflation))
+
+ settings.automaticFontSizeAdjustment = true
+ assertThat("Gecko font scale should match system font scale",
+ settings.fontSizeFactor.toDouble(), closeTo(expectedFontSizeFactor.toDouble(), 0.05))
+ assertThat("font inflation enabled",
+ settings.fontInflationEnabled, `is`(initialFontInflation))
+
+ settings.automaticFontSizeAdjustment = false
+ assertThat("Gecko font scale restored to previous value",
+ settings.fontSizeFactor.toDouble(), closeTo(initialFontSize.toDouble(), 0.05))
+ assertThat("font inflation restored to previous value",
+ settings.fontInflationEnabled, `is`(initialFontInflation))
+ }
+
+ @Ignore // Bug 1546297 disabled test on pgo for frequent failures
+ @Test fun fontSize() {
+ val settings = sessionRule.runtime.settings
+ settings.fontSizeFactor = 1.0f
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ val fontSizeJs = "parseFloat(window.getComputedStyle(document.querySelector('p')).fontSize)"
+ val initialFontSize = sessionRule.session.evaluateJS(fontSizeJs) as Double
+
+ val textSizeFactor = 2.0f
+ settings.fontSizeFactor = textSizeFactor
+ sessionRule.session.reload()
+ sessionRule.waitForPageStop()
+ var fontSize = sessionRule.session.evaluateJS(fontSizeJs) as Double
+ val expectedFontSize = initialFontSize * textSizeFactor
+ assertThat("old text size ${initialFontSize}px, new size should be ${expectedFontSize}px",
+ fontSize, closeTo(expectedFontSize, 0.1))
+
+ settings.fontSizeFactor = 1.0f
+ sessionRule.session.reload()
+ sessionRule.waitForPageStop()
+ fontSize = sessionRule.session.evaluateJS(fontSizeJs) as Double
+ assertThat("text size should be ${initialFontSize}px again",
+ fontSize, closeTo(initialFontSize, 0.1))
+ }
+
+ @Test fun fontInflation() {
+ val baseFontInflationMinTwips = 120
+ val settings = sessionRule.runtime.settings
+
+ settings.fontInflationEnabled = false;
+ settings.fontSizeFactor = 1.0f
+ val fontInflationPref = "font.size.inflation.minTwips"
+
+ var prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int)
+ assertThat("Gecko font inflation pref should be turned off",
+ prefValue, `is`(0))
+
+ settings.fontInflationEnabled = true;
+ prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int)
+ assertThat("Gecko font inflation pref should be turned on",
+ prefValue, `is`(baseFontInflationMinTwips))
+
+ settings.fontSizeFactor = 2.0f
+ prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int)
+ assertThat("Gecko font inflation pref should scale with increased font size factor",
+ prefValue, greaterThan(baseFontInflationMinTwips))
+
+ settings.fontSizeFactor = 0.5f
+ prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int)
+ assertThat("Gecko font inflation pref should scale with decreased font size factor",
+ prefValue, lessThan(baseFontInflationMinTwips))
+
+ settings.fontSizeFactor = 0.0f
+ prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int)
+ assertThat("setting font size factor to 0 turns off font inflation",
+ prefValue, `is`(0))
+ assertThat("GeckoRuntimeSettings returns new font inflation state, too",
+ settings.fontInflationEnabled, `is`(false))
+
+ settings.fontSizeFactor = 1.0f
+ prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int)
+ assertThat("Gecko font inflation pref remains turned off",
+ prefValue, `is`(0))
+ assertThat("GeckoRuntimeSettings remains turned off",
+ settings.fontInflationEnabled, `is`(false))
+ }
+
+ @Test
+ fun aboutConfig() {
+ // This is broken in automation because document channel is enabled by default
+ assumeThat(sessionRule.env.isAutomation, equalTo(false))
+ val settings = sessionRule.runtime.settings
+
+ assertThat("about:config should be disabled by default",
+ settings.aboutConfigEnabled, equalTo(false))
+
+ mainSession.loadUri("about:config")
+ mainSession.waitUntilCalled(object : Callbacks.NavigationDelegate {
+ @AssertCalled
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError):
+ GeckoResult<String>? {
+ assertThat("about:config should not load.", uri, equalTo("about:config"))
+ return null
+ }
+ })
+
+ settings.aboutConfigEnabled = true
+
+ mainSession.delegateDuringNextWait(object : Callbacks.ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("about:config load should succeed", success, equalTo(true))
+ }
+ })
+
+ mainSession.loadUri("about:config")
+ mainSession.waitForPageStop()
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt
new file mode 100644
index 0000000000..9293eba310
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt
@@ -0,0 +1,419 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+
+import android.graphics.*
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.view.Surface
+import org.hamcrest.Matchers.*
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.ExpectedException
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoResult.OnExceptionListener
+import org.mozilla.geckoview.GeckoResult.fromException
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+import kotlin.math.absoluteValue
+import kotlin.math.max
+import android.graphics.BitmapFactory
+import android.graphics.Bitmap
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assume.assumeThat
+import java.lang.IllegalStateException
+import java.lang.NullPointerException
+
+
+private const val SCREEN_HEIGHT = 800
+private const val SCREEN_WIDTH = 800
+private const val BIG_SCREEN_HEIGHT = 999999
+private const val BIG_SCREEN_WIDTH = 999999
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ScreenshotTest : BaseSessionTest() {
+
+ @get:Rule
+ val expectedEx: ExpectedException = ExpectedException.none()
+
+ private fun getComparisonScreenshot(width: Int, height: Int): Bitmap {
+ val screenshotFile = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(screenshotFile)
+ val paint = Paint()
+ paint.shader = LinearGradient(0f, 0f, width.toFloat(), height.toFloat(), Color.RED, Color.WHITE, Shader.TileMode.MIRROR)
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
+ return screenshotFile
+ }
+
+ companion object {
+ /**
+ * Compares two Bitmaps and returns the largest color element difference (red, green or blue)
+ */
+ public fun imageElementDifference(b1: Bitmap, b2: Bitmap): Int {
+ return if (b1.width == b2.width && b1.height == b2.height) {
+ val pixels1 = IntArray(b1.width * b1.height)
+ val pixels2 = IntArray(b2.width * b2.height)
+ b1.getPixels(pixels1, 0, b1.width, 0, 0, b1.width, b1.height)
+ b2.getPixels(pixels2, 0, b2.width, 0, 0, b2.width, b2.height)
+ var maxDiff = 0
+ for (i in 0 until pixels1.size) {
+ val redDiff = (Color.red(pixels1[i]) - Color.red(pixels2[i])).absoluteValue
+ val greenDiff = (Color.green(pixels1[i]) - Color.green(pixels2[i])).absoluteValue
+ val blueDiff = (Color.blue(pixels1[i]) - Color.blue(pixels2[i])).absoluteValue
+ maxDiff = max(maxDiff, max(redDiff, max(greenDiff, blueDiff)))
+ }
+ maxDiff
+ } else {
+ 256
+ }
+ }
+ }
+
+ private fun assertScreenshotResult(result: GeckoResult<Bitmap>, comparisonImage: Bitmap) {
+ sessionRule.waitForResult(result).let {
+ assertThat("Screenshot is not null",
+ it, notNullValue())
+ assertThat("Widths are the same", comparisonImage.width, equalTo(it.width))
+ assertThat("Heights are the same", comparisonImage.height, equalTo(it.height))
+ assertThat("Byte counts are the same", comparisonImage.byteCount, equalTo(it.byteCount))
+ assertThat("Configs are the same", comparisonImage.config, equalTo(it.config))
+ assertThat("Images are almost identical",
+ imageElementDifference(comparisonImage, it), lessThanOrEqualTo(1))
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun capturePixelsSucceeds() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun capturePixelsCanBeCalledMultipleTimes() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ val call1 = it.capturePixels()
+ val call2 = it.capturePixels()
+ val call3 = it.capturePixels()
+ assertScreenshotResult(call1, screenshotFile)
+ assertScreenshotResult(call2, screenshotFile)
+ assertScreenshotResult(call3, screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun capturePixelsCompletesCompositorPausedRestarted() {
+ sessionRule.display?.let {
+ it.surfaceDestroyed()
+ val result = it.capturePixels()
+ val texture = SurfaceTexture(0)
+ texture.setDefaultBufferSize(SCREEN_WIDTH, SCREEN_HEIGHT)
+ val surface = Surface(texture)
+ it.surfaceChanged(surface, SCREEN_WIDTH, SCREEN_HEIGHT)
+ sessionRule.waitForResult(result)
+ }
+ }
+
+ // This tests tries to catch problems like Bug 1644561.
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun capturePixelsStressTest() {
+ val screenshots = mutableListOf<GeckoResult<Bitmap>>()
+ sessionRule.display?.let {
+ for (i in 0..100) {
+ screenshots.add(it.capturePixels())
+ }
+
+ for (i in 0..50) {
+ sessionRule.waitForResult(screenshots[i])
+ }
+
+ it.surfaceDestroyed()
+ screenshots.add(it.capturePixels())
+ it.surfaceDestroyed()
+
+ val texture = SurfaceTexture(0)
+ texture.setDefaultBufferSize(SCREEN_WIDTH, SCREEN_HEIGHT)
+ val surface = Surface(texture)
+ it.surfaceChanged(surface, SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ for (i in 0..100) {
+ screenshots.add(it.capturePixels())
+ }
+
+ for (i in 0..100) {
+ it.surfaceDestroyed()
+ screenshots.add(it.capturePixels())
+ val newTexture = SurfaceTexture(0)
+ newTexture.setDefaultBufferSize(SCREEN_WIDTH, SCREEN_HEIGHT)
+ val newSurface = Surface(newTexture)
+ it.surfaceChanged(newSurface, SCREEN_WIDTH, SCREEN_HEIGHT)
+ }
+
+ try {
+ for (result in screenshots) {
+ sessionRule.waitForResult(result)
+ }
+ } catch (ex: RuntimeException) {
+ // Rejecting the screenshot is fine
+ }
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test(expected = IllegalStateException::class)
+ fun capturePixelsFailsCompositorPaused() {
+ sessionRule.display?.let {
+ it.surfaceDestroyed()
+ val result = it.capturePixels()
+ it.surfaceDestroyed()
+
+ sessionRule.waitForResult(result)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun capturePixelsWhileSessionDeactivated() {
+ // TODO: Bug 1673955
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.session.setActive(false)
+
+ // Deactivating the session should trigger a flush state change
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStateChange(session: GeckoSession,
+ sessionState: GeckoSession.SessionState) {}
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenshotToBitmap() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.screenshot().capture(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenshotScaledToSize() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.screenshot().size(SCREEN_WIDTH/2, SCREEN_HEIGHT/2).capture(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenShotScaledWithScale() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.screenshot().scale(0.5f).capture(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenShotScaledWithAspectPreservingSize() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.screenshot().aspectPreservingSize(SCREEN_WIDTH/2).capture(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun recycleBitmap() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ val call1 = it.screenshot().capture()
+ assertScreenshotResult(call1, screenshotFile)
+ val call2 = it.screenshot().bitmap(call1.poll(1000)).capture()
+ assertScreenshotResult(call2, screenshotFile)
+ val call3 = it.screenshot().bitmap(call2.poll(1000)).capture()
+ assertScreenshotResult(call3, screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenshotWholeRegion() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.screenshot().source(0,0,SCREEN_WIDTH, SCREEN_HEIGHT).capture(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenshotWholeRegionScaled() {
+ val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.screenshot()
+ .source(0,0,SCREEN_WIDTH, SCREEN_HEIGHT)
+ .size(SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+ .capture(), screenshotFile)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenshotQuarters() {
+ val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(
+ it.screenshot()
+ .source(0,0,SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+ .capture(), BitmapFactory.decodeResource(res, R.drawable.colors_tl))
+ assertScreenshotResult(
+ it.screenshot()
+ .source(SCREEN_WIDTH/2,SCREEN_HEIGHT/2,SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+ .capture(), BitmapFactory.decodeResource(res, R.drawable.colors_br))
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun screenshotQuartersScaled() {
+ val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(
+ it.screenshot()
+ .source(0,0,SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+ .size(SCREEN_WIDTH/4, SCREEN_WIDTH/4)
+ .capture(), BitmapFactory.decodeResource(res, R.drawable.colors_tl_scaled))
+ assertScreenshotResult(
+ it.screenshot()
+ .source(SCREEN_WIDTH/2,SCREEN_HEIGHT/2,SCREEN_WIDTH/2, SCREEN_HEIGHT/2)
+ .size(SCREEN_WIDTH/4, SCREEN_WIDTH/4)
+ .capture(), BitmapFactory.decodeResource(res, R.drawable.colors_br_scaled))
+ }
+ }
+
+ @WithDisplay(height = BIG_SCREEN_HEIGHT, width = BIG_SCREEN_WIDTH)
+ @Test
+ fun giantScreenshot() {
+ sessionRule.session.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.display?.screenshot()!!.source(0,0, BIG_SCREEN_WIDTH, BIG_SCREEN_HEIGHT)
+ .size(BIG_SCREEN_WIDTH, BIG_SCREEN_HEIGHT)
+ .capture()
+ .exceptionally(OnExceptionListener<Throwable> { error: Throwable ->
+ Assert.assertTrue(error is OutOfMemoryError)
+ fromException(error)
+ })
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt
new file mode 100644
index 0000000000..588617e27b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt
@@ -0,0 +1,495 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.graphics.RectF;
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.MediumTest
+
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers.*
+import org.json.JSONArray
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.junit.runners.Parameterized.Parameter
+import org.junit.runners.Parameterized.Parameters
+import org.mozilla.geckoview.GeckoSession
+
+@MediumTest
+@RunWith(Parameterized::class)
+@WithDisplay(width = 100, height = 100)
+class SelectionActionDelegateTest : BaseSessionTest() {
+ enum class ContentType {
+ DIV, EDITABLE_ELEMENT, IFRAME
+ }
+
+ companion object {
+ @get:Parameters(name = "{0}")
+ @JvmStatic
+ val parameters: List<Array<out Any>> = listOf(
+ arrayOf("#text", ContentType.DIV, "lorem", false),
+ arrayOf("#input", ContentType.EDITABLE_ELEMENT, "ipsum", true),
+ arrayOf("#textarea", ContentType.EDITABLE_ELEMENT, "dolor", true),
+ arrayOf("#contenteditable", ContentType.DIV, "sit", true),
+ arrayOf("#iframe", ContentType.IFRAME, "amet", false),
+ arrayOf("#designmode", ContentType.IFRAME, "consectetur", true))
+ }
+
+ @field:Parameter(0) @JvmField var id: String = ""
+ @field:Parameter(1) @JvmField var type: ContentType = ContentType.DIV
+ @field:Parameter(2) @JvmField var initialContent: String = ""
+ @field:Parameter(3) @JvmField var editable: Boolean = false
+
+ private val selectedContent by lazy {
+ when (type) {
+ ContentType.DIV -> SelectedDiv(id, initialContent)
+ ContentType.EDITABLE_ELEMENT -> SelectedEditableElement(id, initialContent)
+ ContentType.IFRAME -> SelectedFrame(id, initialContent)
+ }
+ }
+
+ private val collapsedContent by lazy {
+ when (type) {
+ ContentType.DIV -> CollapsedDiv(id)
+ ContentType.EDITABLE_ELEMENT -> CollapsedEditableElement(id)
+ ContentType.IFRAME -> CollapsedFrame(id)
+ }
+ }
+
+
+ /** Generic tests for each content type. */
+
+ @Test fun request() {
+ if (editable) {
+ withClipboard ("text") {
+ testThat(selectedContent, {}, hasShowActionRequest(
+ FLAG_IS_EDITABLE, arrayOf(ACTION_COLLAPSE_TO_START, ACTION_COLLAPSE_TO_END,
+ ACTION_COPY, ACTION_CUT, ACTION_DELETE,
+ ACTION_HIDE, ACTION_PASTE)))
+ }
+ } else {
+ testThat(selectedContent, {}, hasShowActionRequest(
+ 0, arrayOf(ACTION_COPY, ACTION_HIDE, ACTION_SELECT_ALL,
+ ACTION_UNSELECT)))
+ }
+ }
+
+ @Test fun request_collapsed() = assumingEditable(true) {
+ withClipboard ("text") {
+ testThat(collapsedContent, {}, hasShowActionRequest(
+ FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED,
+ arrayOf(ACTION_HIDE, ACTION_PASTE, ACTION_SELECT_ALL)))
+ }
+ }
+
+ @Test fun request_noClipboard() = assumingEditable(true) {
+ withClipboard("") {
+ testThat(collapsedContent, {}, hasShowActionRequest(
+ FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED,
+ arrayOf(ACTION_HIDE, ACTION_SELECT_ALL)))
+ }
+ }
+
+ @Test fun hide() = testThat(selectedContent, withResponse(ACTION_HIDE), clearsSelection())
+
+ @Test fun cut() = assumingEditable(true) {
+ withClipboard("") {
+ testThat(selectedContent, withResponse(ACTION_CUT), copiesText(), deletesContent())
+ }
+ }
+
+ @Test fun copy() = withClipboard("") {
+ testThat(selectedContent, withResponse(ACTION_COPY), copiesText())
+ }
+
+ @Test fun paste() = assumingEditable(true) {
+ withClipboard("pasted") {
+ testThat(selectedContent, withResponse(ACTION_PASTE), changesContentTo("pasted"))
+ }
+ }
+
+ @Test fun delete() = assumingEditable(true) {
+ testThat(selectedContent, withResponse(ACTION_DELETE), deletesContent())
+ }
+
+ @Test fun selectAll() {
+ if (type == ContentType.DIV && !editable) {
+ // "Select all" for non-editable div means selecting the whole document.
+ testThat(selectedContent, withResponse(ACTION_SELECT_ALL), changesSelectionTo(
+ both(containsString(selectedContent.initialContent))
+ .and(not(equalTo(selectedContent.initialContent)))))
+ } else {
+ testThat(if (editable) collapsedContent else selectedContent,
+ withResponse(ACTION_SELECT_ALL),
+ changesSelectionTo(selectedContent.initialContent))
+ }
+ }
+
+ @Test fun unselect() = assumingEditable(false) {
+ testThat(selectedContent, withResponse(ACTION_UNSELECT), clearsSelection())
+ }
+
+ @Test fun multipleActions() = assumingEditable(false) {
+ withClipboard("") {
+ testThat(selectedContent, withResponse(ACTION_COPY, ACTION_UNSELECT),
+ copiesText(), clearsSelection())
+ }
+ }
+
+ @Test fun collapseToStart() = assumingEditable(true) {
+ testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_START), hasSelectionAt(0))
+ }
+
+ @Test fun collapseToEnd() = assumingEditable(true) {
+ testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_END),
+ hasSelectionAt(selectedContent.initialContent.length))
+ }
+
+ @Test fun pagehide() {
+ // Navigating to another page should hide selection action.
+ testThat(selectedContent, { mainSession.loadTestPath(HELLO_HTML_PATH) }, clearsSelection())
+ }
+
+ @Test fun deactivate() {
+ // Blurring the window should hide selection action.
+ testThat(selectedContent, { mainSession.setFocused(false) }, clearsSelection())
+ mainSession.setFocused(true)
+ }
+
+ @NullDelegate(GeckoSession.SelectionActionDelegate::class)
+ @Test fun clearDelegate() {
+ var counter = 0
+ mainSession.selectionActionDelegate = object : Callbacks.SelectionActionDelegate {
+ override fun onHideAction(session: GeckoSession, reason: Int) {
+ counter++
+ }
+ }
+
+ mainSession.selectionActionDelegate = null
+ assertThat("Hide action should be called when clearing delegate",
+ counter, equalTo(1))
+ }
+
+ @Test fun compareClientRect() {
+ val jsCssReset = """(function() {
+ document.querySelector('${id}').style.display = "block";
+ document.querySelector('${id}').style.border = "0";
+ document.querySelector('${id}').style.padding = "0";
+ })()"""
+ val jsBorder10pxPadding10px = """(function() {
+ document.querySelector('${id}').style.display = "block";
+ document.querySelector('${id}').style.border = "10px solid";
+ document.querySelector('${id}').style.padding = "10px";
+ })()"""
+ val expectedDiff = RectF(20f, 20f, 20f, 20f) // left, top, right, bottom
+ testClientRect(selectedContent, jsCssReset, jsBorder10pxPadding10px, expectedDiff)
+ }
+
+ /** Interface that defines behavior for a particular type of content */
+ private interface SelectedContent {
+ fun focus() {}
+ fun select() {}
+ val initialContent: String
+ val content: String
+ val selectionOffsets: Pair<Int, Int>
+ }
+
+ /** Main method that performs test logic. */
+ private fun testThat(content: SelectedContent,
+ respondingWith: (Selection) -> Unit,
+ result: (SelectedContent) -> Unit,
+ vararg sideEffects: (SelectedContent) -> Unit) {
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ content.focus()
+
+ // Show selection actions for collapsed selections, so we can test them.
+ // Also, always show accessible carets / selection actions for changes due to JS calls.
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "geckoview.selection_action.show_on_focus" to true,
+ "layout.accessiblecaret.script_change_update_mode" to 2))
+
+ mainSession.delegateDuringNextWait(object : Callbacks.SelectionActionDelegate {
+ override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection) {
+ respondingWith(selection)
+ }
+ })
+
+ content.select()
+ mainSession.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowActionRequest(session: GeckoSession, selection: Selection) {
+ assertThat("Initial content should match",
+ selection.text, equalTo(content.initialContent))
+ }
+ })
+
+ result(content)
+ sideEffects.forEach { it(content) }
+ }
+
+ private fun testClientRect(content: SelectedContent,
+ initialJsA: String,
+ initialJsB: String,
+ expectedDiff: RectF) {
+
+ // Show selection actions for collapsed selections, so we can test them.
+ // Also, always show accessible carets / selection actions for changes due to JS calls.
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "geckoview.selection_action.show_on_focus" to true,
+ "layout.accessiblecaret.script_change_update_mode" to 2))
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ val requestClientRect: (String) -> RectF = {
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS(it)
+ content.focus()
+
+ var clientRect = RectF()
+ content.select()
+ mainSession.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowActionRequest(session: GeckoSession, selection: Selection) {
+ clientRect = selection.clientRect!!
+ }
+ })
+
+ clientRect
+ }
+
+ val clientRectA = requestClientRect(initialJsA)
+ val clientRectB = requestClientRect(initialJsB)
+
+ val fuzzyEqual = { a: Float, b: Float, e: Float -> Math.abs(a + e - b) <= 1 }
+ val result = fuzzyEqual(clientRectA.top, clientRectB.top, expectedDiff.top)
+ && fuzzyEqual(clientRectA.left, clientRectB.left, expectedDiff.left)
+ && fuzzyEqual(clientRectA.width(), clientRectB.width(), expectedDiff.width())
+ && fuzzyEqual(clientRectA.height(), clientRectB.height(), expectedDiff.height())
+
+ assertThat("Selection rect is not at expected location. a$clientRectA b$clientRectB",
+ result, equalTo(true))
+ }
+
+
+ /** Helpers. */
+
+ private val clipboard by lazy {
+ InstrumentationRegistry.getInstrumentation().targetContext.getSystemService(Context.CLIPBOARD_SERVICE)
+ as ClipboardManager
+ }
+
+ private fun withClipboard(content: String = "", lambda: () -> Unit) {
+ val oldClip = clipboard.primaryClip
+ try {
+ clipboard.setPrimaryClip(ClipData.newPlainText("", content))
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ ClipboardManager.OnPrimaryClipChangedListener::class,
+ clipboard::addPrimaryClipChangedListener,
+ clipboard::removePrimaryClipChangedListener,
+ ClipboardManager.OnPrimaryClipChangedListener {})
+ lambda()
+ } finally {
+ clipboard.setPrimaryClip(oldClip ?: ClipData.newPlainText("", ""))
+ }
+ }
+
+ private fun assumingEditable(editable: Boolean, lambda: (() -> Unit)? = null) {
+ assumeThat("Assuming is ${if (editable) "" else "not "}editable",
+ this.editable, equalTo(editable))
+ lambda?.invoke()
+ }
+
+
+ /** Behavior objects for different content types */
+
+ open inner class SelectedDiv(val id: String,
+ override val initialContent: String) : SelectedContent {
+ protected fun selectTo(to: Int) {
+ mainSession.evaluateJS("""document.getSelection().setBaseAndExtent(
+ document.querySelector('$id').firstChild, 0,
+ document.querySelector('$id').firstChild, $to)""")
+ }
+
+ override fun select() = selectTo(initialContent.length)
+
+ override val content: String get() {
+ return mainSession.evaluateJS("document.querySelector('$id').textContent") as String
+ }
+
+ override val selectionOffsets: Pair<Int, Int> get() {
+ if (mainSession.evaluateJS("""
+ document.getSelection().anchorNode !== document.querySelector('$id').firstChild ||
+ document.getSelection().focusNode !== document.querySelector('$id').firstChild""") as Boolean) {
+ return Pair(-1, -1)
+ }
+ val offsets = mainSession.evaluateJS("""[
+ document.getSelection().anchorOffset,
+ document.getSelection().focusOffset]""") as JSONArray
+ return Pair(offsets[0] as Int, offsets[1] as Int)
+ }
+ }
+
+ inner class CollapsedDiv(id: String) : SelectedDiv(id, "") {
+ override fun select() = selectTo(0)
+ }
+
+ open inner class SelectedEditableElement(
+ val id: String, override val initialContent: String) : SelectedContent {
+ override fun focus() {
+ mainSession.waitForJS("document.querySelector('$id').focus()")
+ }
+
+ override fun select() {
+ mainSession.evaluateJS("document.querySelector('$id').select()")
+ }
+
+ override val content: String get() {
+ return mainSession.evaluateJS("document.querySelector('$id').value") as String
+ }
+
+ override val selectionOffsets: Pair<Int, Int> get() {
+ val offsets = mainSession.evaluateJS(
+ """[ document.querySelector('$id').selectionStart,
+ |document.querySelector('$id').selectionEnd ]""".trimMargin()) as JSONArray
+ return Pair(offsets[0] as Int, offsets[1] as Int)
+ }
+ }
+
+ inner class CollapsedEditableElement(id: String) : SelectedEditableElement(id, "") {
+ override fun select() {
+ mainSession.evaluateJS("document.querySelector('$id').setSelectionRange(0, 0)")
+ }
+ }
+
+ open inner class SelectedFrame(val id: String,
+ override val initialContent: String) : SelectedContent {
+ override fun focus() {
+ mainSession.evaluateJS("document.querySelector('$id').contentWindow.focus()")
+ }
+
+ protected fun selectTo(to: Int) {
+ mainSession.evaluateJS("""(function() {
+ var doc = document.querySelector('$id').contentDocument;
+ var text = doc.body.firstChild;
+ doc.getSelection().setBaseAndExtent(text, 0, text, $to);
+ })()""")
+ }
+
+ override fun select() = selectTo(initialContent.length)
+
+ override val content: String get() {
+ return mainSession.evaluateJS("document.querySelector('$id').contentDocument.body.textContent") as String
+ }
+
+ override val selectionOffsets: Pair<Int, Int> get() {
+ val offsets = mainSession.evaluateJS("""(function() {
+ var sel = document.querySelector('$id').contentDocument.getSelection();
+ var text = document.querySelector('$id').contentDocument.body.firstChild;
+ if (sel.anchorNode !== text || sel.focusNode !== text) {
+ return [-1, -1];
+ }
+ return [sel.anchorOffset, sel.focusOffset];
+ })()""") as JSONArray
+ return Pair(offsets[0] as Int, offsets[1] as Int)
+ }
+ }
+
+ inner class CollapsedFrame(id: String) : SelectedFrame(id, "") {
+ override fun select() = selectTo(0)
+ }
+
+
+ /** Lambda for responding with certain actions. */
+
+ private fun withResponse(vararg actions: String): (Selection) -> Unit {
+ var responded = false
+ return { response ->
+ if (!responded) {
+ responded = true
+ actions.forEach { response.execute(it) }
+ }
+ }
+ }
+
+
+ /** Lambdas for asserting the results of actions. */
+
+ private fun hasShowActionRequest(expectedFlags: Int,
+ expectedActions: Array<out String>) = { it: SelectedContent ->
+ mainSession.forCallbacksDuringWait(object : Callbacks.SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection) {
+ assertThat("Selection text should be valid",
+ selection.text, equalTo(it.initialContent))
+ assertThat("Selection flags should be valid",
+ selection.flags, equalTo(expectedFlags))
+ assertThat("Selection rect should be valid",
+ selection.clientRect!!.isEmpty, equalTo(false))
+ assertThat("Actions must be valid", selection.availableActions.toTypedArray(),
+ arrayContainingInAnyOrder(*expectedActions))
+ }
+ })
+ }
+
+ private fun copiesText() = { it: SelectedContent ->
+ sessionRule.waitUntilCalled(ClipboardManager.OnPrimaryClipChangedListener {
+ assertThat("Clipboard should contain correct text",
+ clipboard.primaryClip?.getItemAt(0)?.text,
+ hasToString(it.initialContent))
+ })
+ }
+
+ private fun changesSelectionTo(text: String) = changesSelectionTo(equalTo(text))
+
+ private fun changesSelectionTo(matcher: Matcher<String>) = { _: SelectedContent ->
+ sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowActionRequest(session: GeckoSession, selection: Selection) {
+ assertThat("New selection text should match", selection.text, matcher)
+ }
+ })
+ }
+
+ private fun clearsSelection() = { _: SelectedContent ->
+ sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onHideAction(session: GeckoSession, reason: Int) {
+ assertThat("Hide reason should be correct",
+ reason, equalTo(HIDE_REASON_NO_SELECTION))
+ }
+ })
+ }
+
+ private fun hasSelectionAt(offset: Int) = hasSelectionAt(offset, offset)
+
+ private fun hasSelectionAt(start: Int, end: Int) = { it: SelectedContent ->
+ assertThat("Selection offsets should match",
+ it.selectionOffsets, equalTo(Pair(start, end)))
+ }
+
+ private fun deletesContent() = changesContentTo("")
+
+ private fun changesContentTo(content: String) = { it: SelectedContent ->
+ assertThat("Changed content should match", it.content, equalTo(content))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt
new file mode 100644
index 0000000000..5ea0be06fe
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt
@@ -0,0 +1,165 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoRuntimeSettings
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+import android.os.Bundle
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.lang.ref.ReferenceQueue
+import java.lang.ref.WeakReference
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class SessionLifecycleTest : BaseSessionTest() {
+ companion object {
+ val LOGTAG = "SessionLifecycleTest"
+ }
+
+ @Test fun open_interleaved() {
+ val session1 = sessionRule.createOpenSession()
+ val session2 = sessionRule.createOpenSession()
+ session1.close()
+ val session3 = sessionRule.createOpenSession()
+ session2.close()
+ session3.close()
+
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+ }
+
+ @Test fun open_repeated() {
+ for (i in 1..5) {
+ sessionRule.session.close()
+ sessionRule.session.open()
+ }
+ sessionRule.session.reload()
+ sessionRule.session.waitForPageStop()
+ }
+
+ @Test fun open_allowCallsWhileClosed() {
+ sessionRule.session.close()
+
+ sessionRule.session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.session.reload()
+
+ sessionRule.session.open()
+ sessionRule.session.waitForPageStops(2)
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun open_throwOnAlreadyOpen() {
+ // Throw exception if retrying to open again; otherwise we would leak the old open window.
+ sessionRule.session.open()
+ }
+
+ @ClosedSessionAtStart
+ @Test fun restoreRuntimeSettings_noSession() {
+ val extrasSetting = Bundle(2)
+ extrasSetting.putInt("test1", 10)
+ extrasSetting.putBoolean("test2", true)
+
+ val settings = GeckoRuntimeSettings.Builder()
+ .javaScriptEnabled(false)
+ .extras(extrasSetting)
+ .build()
+
+ settings.toParcel { parcel ->
+ val newSettings = GeckoRuntimeSettings.Builder().build()
+ newSettings.readFromParcel(parcel)
+
+ assertThat("Parceled settings must match",
+ newSettings.javaScriptEnabled,
+ equalTo(settings.javaScriptEnabled))
+ assertThat("Parceled settings must match",
+ newSettings.extras.getInt("test1"),
+ equalTo(settings.extras.getInt("test1")))
+ assertThat("Parceled settings must match",
+ newSettings.extras.getBoolean("test2"),
+ equalTo(settings.extras.getBoolean("test2")))
+ }
+ }
+
+ @Test fun collectClosed() {
+ // We can't use a normal scoped function like `run` because
+ // those are inlined, which leaves a local reference.
+ fun createSession(): QueuedWeakReference<GeckoSession> {
+ return QueuedWeakReference<GeckoSession>(GeckoSession())
+ }
+
+ waitUntilCollected(createSession())
+ }
+
+ @Test fun collectAfterClose() {
+ fun createSession(): QueuedWeakReference<GeckoSession> {
+ val s = GeckoSession()
+ s.open(sessionRule.runtime)
+ s.close()
+ return QueuedWeakReference<GeckoSession>(s)
+ }
+
+ waitUntilCollected(createSession())
+ }
+
+ @Test fun collectOpen() {
+ fun createSession(): QueuedWeakReference<GeckoSession> {
+ val s = GeckoSession()
+ s.open(sessionRule.runtime)
+ return QueuedWeakReference<GeckoSession>(s)
+ }
+
+ waitUntilCollected(createSession())
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test fun asyncScriptsSuspendedWhileInactive() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ assertThat("docShell should start active", mainSession.active, equalTo(true))
+
+ // Deactivate the GeckoSession and confirm that rAF/setTimeout/etc callbacks do not run
+ mainSession.setActive(false)
+ mainSession.evaluateJS(
+ """function fail() {
+ document.documentElement.style.backgroundColor = 'green';
+ }
+ requestAnimationFrame(fail);
+ setTimeout(fail, 1);
+ fetch("missing.html").catch(fail);""")
+ mainSession.waitForJS("new Promise(resolve => { resolve() })")
+ val isNotGreen = mainSession.evaluateJS("document.documentElement.style.backgroundColor !== 'green'") as Boolean
+ assertThat("requestAnimationFrame has not run yet", isNotGreen, equalTo(true))
+ assertThat("docShell shouldn't be active after calling setActive",
+ mainSession.active, equalTo(false))
+
+ // Reactivate the GeckoSession and confirm that rAF/setTimeout/etc callbacks now run
+ mainSession.setActive(true)
+ assertThat("docShell should be active after calling setActive(true)",
+ mainSession.active, equalTo(true))
+ mainSession.waitForJS("new Promise(resolve => requestAnimationFrame(() => { resolve(); }))");
+ var isGreen = mainSession.evaluateJS("document.documentElement.style.backgroundColor === 'green'") as Boolean
+ assertThat("requestAnimationFrame has run", isGreen, equalTo(true))
+ }
+
+ private fun waitUntilCollected(ref: QueuedWeakReference<*>) {
+ UiThreadUtils.waitForCondition({
+ Runtime.getRuntime().gc()
+ ref.queue.poll() != null
+ }, sessionRule.timeoutMillis)
+ }
+
+ class QueuedWeakReference<T> @JvmOverloads constructor(obj: T, var queue: ReferenceQueue<T> =
+ ReferenceQueue()) : WeakReference<T>(obj, queue)
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt
new file mode 100644
index 0000000000..25bbdedaf7
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt
@@ -0,0 +1,405 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoSessionSettings
+import org.mozilla.geckoview.StorageController
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class StorageControllerTest : BaseSessionTest() {
+
+ @Test fun clearData() {
+ sessionRule.session.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.evaluateJS("""
+ localStorage.setItem('ctx', 'test');
+ document.cookie = 'ctx=test';
+ """)
+
+ var localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ var cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("test"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("ctx=test"))
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearData(
+ StorageController.ClearFlags.ALL))
+
+ localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("null"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("null"))
+ }
+
+ @Test fun clearDataFlags() {
+ sessionRule.session.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.evaluateJS("""
+ localStorage.setItem('ctx', 'test');
+ document.cookie = 'ctx=test';
+ """)
+
+ var localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ var cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("test"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("ctx=test"))
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearData(
+ StorageController.ClearFlags.COOKIES))
+
+ localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ // With LSNG disabled, storage is also cleared when cookies are,
+ // see bug 1592752.
+ if (sessionRule.getPrefs("dom.storage.next_gen")[0] as Boolean == true) {
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("test"))
+ } else {
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("null"))
+ }
+
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("null"))
+
+ sessionRule.session.evaluateJS("""
+ document.cookie = 'ctx=test';
+ """)
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearData(
+ StorageController.ClearFlags.DOM_STORAGES))
+
+ localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("null"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("ctx=test"))
+
+ sessionRule.session.evaluateJS("""
+ localStorage.setItem('ctx', 'test');
+ """)
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearData(
+ StorageController.ClearFlags.SITE_DATA))
+
+ localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("null"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("null"))
+ }
+
+ @Test fun clearDataFromHost() {
+ sessionRule.session.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.session.evaluateJS("""
+ localStorage.setItem('ctx', 'test');
+ document.cookie = 'ctx=test';
+ """)
+
+ var localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ var cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("test"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("ctx=test"))
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearDataFromHost(
+ "test.com",
+ StorageController.ClearFlags.ALL))
+
+ localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("test"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("ctx=test"))
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearDataFromHost(
+ "example.com",
+ StorageController.ClearFlags.ALL))
+
+ localStorage = sessionRule.session.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ cookie = sessionRule.session.evaluateJS("""
+ document.cookie || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("null"))
+ assertThat("Cookie value should match",
+ cookie,
+ equalTo("null"))
+ }
+
+ private fun testSessionContext(baseSettings: GeckoSessionSettings) {
+ val session1 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(baseSettings)
+ .contextId("1")
+ .build())
+ session1.loadUri("https://example.com")
+ session1.waitForPageStop()
+
+ session1.evaluateJS("""
+ localStorage.setItem('ctx', '1');
+ """)
+
+ var localStorage = session1.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("1"))
+
+ session1.reload()
+ session1.waitForPageStop()
+
+ localStorage = session1.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("1"))
+
+ val session2 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(baseSettings)
+ .contextId("2")
+ .build())
+
+ session2.loadUri("https://example.com")
+ session2.waitForPageStop()
+
+ localStorage = session2.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should be null",
+ localStorage,
+ equalTo("null"))
+
+ session2.evaluateJS("""
+ localStorage.setItem('ctx', '2');
+ """)
+
+ localStorage = session2.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("2"))
+
+ session1.loadUri("https://example.com")
+ session1.waitForPageStop()
+
+ localStorage = session1.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("1"))
+
+ val session3 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(baseSettings)
+ .contextId("2")
+ .build())
+
+ session3.loadUri("https://example.com")
+ session3.waitForPageStop()
+
+ localStorage = session3.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("2"))
+ }
+
+ @Test fun sessionContext() {
+ testSessionContext(mainSession.settings)
+ }
+
+ @Test fun sessionContextPrivateMode() {
+ testSessionContext(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .usePrivateMode(true)
+ .build())
+ }
+
+ @Test fun clearDataForSessionContext() {
+ val session1 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("1")
+ .build())
+ session1.loadUri("https://example.com")
+ session1.waitForPageStop()
+
+ session1.evaluateJS("""
+ localStorage.setItem('ctx', '1');
+ """)
+
+ var localStorage = session1.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("1"))
+
+ session1.close()
+
+ val session2 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("2")
+ .build())
+
+ session2.loadUri("https://example.com")
+ session2.waitForPageStop()
+
+ session2.evaluateJS("""
+ localStorage.setItem('ctx', '2');
+ """)
+
+ localStorage = session2.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("2"))
+
+ session2.close()
+
+ sessionRule.runtime.storageController.clearDataForSessionContext("1")
+
+ val session3 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("1")
+ .build())
+
+ session3.loadUri("https://example.com")
+ session3.waitForPageStop()
+
+ localStorage = session3.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("null"))
+
+ val session4 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("2")
+ .build())
+
+ session4.loadUri("https://example.com")
+ session4.waitForPageStop()
+
+ localStorage = session4.evaluateJS("""
+ localStorage.getItem('ctx') || 'null'
+ """) as String
+
+ assertThat("Local storage value should match",
+ localStorage,
+ equalTo("2"))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt
new file mode 100644
index 0000000000..9ba1e9b276
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt
@@ -0,0 +1,123 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.RuntimeTelemetry
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class TelemetryTest : BaseSessionTest() {
+ @Test
+ fun testOnTelemetryReceived() {
+ // Let's make sure we batch the telemetry calls.
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf("toolkit.telemetry.geckoview.batchDurationMS" to 100000))
+
+ val expectedHistograms = listOf<Long>(401, 12, 1, 109, 2000)
+ val receivedHistograms = mutableListOf<Long>()
+ val histogram = GeckoResult<Void>()
+ val stringScalar = GeckoResult<Void>()
+ val booleanScalar = GeckoResult<Void>()
+ val longScalar = GeckoResult<Void>()
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ RuntimeTelemetry.Delegate::class,
+ sessionRule::setTelemetryDelegate,
+ { sessionRule.setTelemetryDelegate(null) },
+ object : RuntimeTelemetry.Delegate {
+ @AssertCalled
+ override fun onHistogram(metric: RuntimeTelemetry.Histogram) {
+ if (metric.name != "TELEMETRY_TEST_STREAMING") {
+ return
+ }
+
+ assertThat(
+ "The histogram should not be categorical",
+ metric.isCategorical,
+ equalTo(false))
+
+ receivedHistograms.addAll(metric.value.toList())
+
+ if (receivedHistograms.size == expectedHistograms.size) {
+ histogram.complete(null)
+ }
+ }
+
+ @AssertCalled
+ override fun onStringScalar(metric: RuntimeTelemetry.Metric<String>) {
+ if (metric.name != "telemetry.test.string_kind") {
+ return
+ }
+
+ assertThat(
+ "Metric value should match",
+ metric.value,
+ equalTo("test scalar"))
+
+ stringScalar.complete(null)
+ }
+
+ @AssertCalled
+ override fun onBooleanScalar(metric: RuntimeTelemetry.Metric<Boolean>) {
+ if (metric.name != "telemetry.test.boolean_kind") {
+ return
+ }
+
+ assertThat(
+ "Metric value should match",
+ metric.value,
+ equalTo(true))
+
+ booleanScalar.complete(null)
+ }
+
+ @AssertCalled
+ override fun onLongScalar(metric: RuntimeTelemetry.Metric<Long>) {
+ if (metric.name != "telemetry.test.unsigned_int_kind") {
+ return
+ }
+
+ assertThat(
+ "Metric value should match",
+ metric.value,
+ equalTo(1234L))
+
+ longScalar.complete(null)
+ }
+ })
+
+ sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[0])
+ sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[1])
+ sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[2])
+ sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[3])
+
+ sessionRule.setScalar("telemetry.test.boolean_kind", true)
+ sessionRule.setScalar("telemetry.test.unsigned_int_kind", 1234)
+ sessionRule.setScalar("telemetry.test.string_kind", "test scalar")
+
+ // Forces flushing telemetry data at next histogram.
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf("toolkit.telemetry.geckoview.batchDurationMS" to 0))
+ sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[4])
+
+ sessionRule.waitForResult(histogram)
+ sessionRule.waitForResult(stringScalar)
+ sessionRule.waitForResult(booleanScalar)
+ sessionRule.waitForResult(longScalar)
+
+ assertThat(
+ "Metric values should match",
+ receivedHistograms,
+ equalTo(expectedHistograms))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java
new file mode 100644
index 0000000000..c922b9bce1
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java
@@ -0,0 +1,268 @@
+package org.mozilla.geckoview.test;
+
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Messenger;
+import android.os.RemoteException;
+
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.test.util.UiThreadUtils;
+
+import java.io.File;
+
+public class TestCrashHandler extends Service {
+ private static final int MSG_EVAL_NEXT_CRASH_DUMP = 1;
+ private static final int MSG_CRASH_DUMP_EVAL_RESULT = 2;
+ private static final String LOGTAG = "TestCrashHandler";
+
+ public static final class EvalResult {
+ private static final String BUNDLE_KEY_RESULT = "TestCrashHandler.EvalResult.mResult";
+ private static final String BUNDLE_KEY_MSG = "TestCrashHandler.EvalResult.mMsg";
+
+ public EvalResult(boolean result, String msg) {
+ mResult = result;
+ mMsg = msg;
+ }
+
+ public EvalResult(Bundle bundle) {
+ mResult = bundle.getBoolean(BUNDLE_KEY_RESULT, false);
+ mMsg = bundle.getString(BUNDLE_KEY_MSG);
+ }
+
+ public Bundle asBundle() {
+ final Bundle bundle = new Bundle();
+ bundle.putBoolean(BUNDLE_KEY_RESULT, mResult);
+ bundle.putString(BUNDLE_KEY_MSG, mMsg);
+ return bundle;
+ }
+
+ public boolean mResult;
+ public String mMsg;
+ }
+
+ public static final class Client {
+ private static final String LOGTAG = "TestCrashHandler.Client";
+
+ private class Receiver extends Handler {
+ public Receiver(final Looper looper) {
+ super(looper);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == MSG_CRASH_DUMP_EVAL_RESULT) {
+ setEvalResult(new EvalResult(msg.getData()));
+ return;
+ }
+
+ super.handleMessage(msg);
+ }
+ }
+
+ private Receiver mReceiver;
+ private boolean mDoUnbind = false;
+ private Messenger mService = null;
+ private Messenger mMessenger;
+ private Context mContext;
+ private HandlerThread mThread;
+ private EvalResult mResult = null;
+
+ private ServiceConnection mConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ mService = new Messenger(service);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName className) {
+ disconnect();
+ }
+ };
+
+ public Client(final Context context) {
+ mContext = context;
+ mThread = new HandlerThread("TestCrashHandler.Client");
+ mThread.start();
+ mReceiver = new Receiver(mThread.getLooper());
+ mMessenger = new Messenger(mReceiver);
+ }
+
+ /**
+ * Tests should call this to notify the crash handler that the next crash it sees is
+ * intentional and that its intent should be checked for correctness.
+ *
+ * @param expectFatal Whether the incoming crash is expected to be fatal or not.
+ */
+ public void setEvalNextCrashDump(final boolean expectFatal) {
+ setEvalResult(null);
+ mReceiver.post(new Runnable() {
+ @Override
+ public void run() {
+ Message msg = Message.obtain(null, MSG_EVAL_NEXT_CRASH_DUMP,
+ expectFatal ? 1 : 0, 0);
+ msg.replyTo = mMessenger;
+
+ try {
+ mService.send(msg);
+ } catch (RemoteException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+ });
+ }
+
+ public boolean connect(final long timeoutMillis) {
+ Intent intent = new Intent(mContext, TestCrashHandler.class);
+ mDoUnbind = mContext.bindService(intent, mConnection, Context.BIND_AUTO_CREATE |
+ Context.BIND_IMPORTANT);
+ if (!mDoUnbind) {
+ return false;
+ }
+
+ UiThreadUtils.waitForCondition(() -> mService != null, timeoutMillis);
+
+ return mService != null;
+ }
+
+ public void disconnect() {
+ if (mDoUnbind) {
+ mContext.unbindService(mConnection);
+ mService = null;
+ mDoUnbind = false;
+ }
+ mThread.quitSafely();
+ }
+
+ private synchronized void setEvalResult(EvalResult result) {
+ mResult = result;
+ }
+
+ private synchronized EvalResult getEvalResult() {
+ return mResult;
+ }
+
+ /**
+ * Tests should call this method after initiating the intentional crash to wait for the
+ * result from the crash handler.
+ *
+ * @param timeoutMillis timeout in milliseconds
+ * @return EvalResult containing the boolean result of the test and an error message.
+ */
+ public EvalResult getEvalResult(final long timeoutMillis) {
+ UiThreadUtils.waitForCondition(() -> getEvalResult() != null, timeoutMillis);
+ return getEvalResult();
+ }
+ }
+
+ private static final class MessageHandler extends Handler {
+ private Messenger mReplyToMessenger;
+ private boolean mExpectFatal = false;
+
+ MessageHandler() {
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == MSG_EVAL_NEXT_CRASH_DUMP) {
+ mReplyToMessenger = msg.replyTo;
+ mExpectFatal = msg.arg1 != 0;
+ return;
+ }
+
+ super.handleMessage(msg);
+ }
+
+ public void reportResult(EvalResult result) {
+ if (mReplyToMessenger == null) {
+ return;
+ }
+
+ Message msg = Message.obtain(null, MSG_CRASH_DUMP_EVAL_RESULT);
+ msg.setData(result.asBundle());
+
+ try {
+ mReplyToMessenger.send(msg);
+ } catch (RemoteException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+
+ mReplyToMessenger = null;
+ }
+
+ public boolean getExpectFatal() {
+ return mExpectFatal;
+ }
+ }
+
+ private Messenger mMessenger;
+ private MessageHandler mMsgHandler;
+
+ public TestCrashHandler() {
+ }
+
+ private EvalResult evalCrashInfo(final Intent intent) {
+ if (!intent.getAction().equals(GeckoRuntime.ACTION_CRASHED)) {
+ return new EvalResult(false, "Action should match");
+ }
+
+ final File dumpFile = new File(intent.getStringExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH));
+ final boolean dumpFileExists = dumpFile.exists();
+ dumpFile.delete();
+
+ final File extrasFile = new File(intent.getStringExtra(GeckoRuntime.EXTRA_EXTRAS_PATH));
+ final boolean extrasFileExists = extrasFile.exists();
+ extrasFile.delete();
+
+ if (!dumpFileExists) {
+ return new EvalResult(false, "Dump file should exist");
+ }
+
+ if (!extrasFileExists) {
+ return new EvalResult(false, "Extras file should exist");
+ }
+
+ final boolean expectFatal = mMsgHandler.getExpectFatal();
+ if (intent.getBooleanExtra(GeckoRuntime.EXTRA_CRASH_FATAL, !expectFatal) != expectFatal) {
+ return new EvalResult(false, "Fatality should match");
+ }
+
+ return new EvalResult(true, "Crash Dump OK");
+ }
+
+ @Override
+ public synchronized int onStartCommand(Intent intent, int flags, int startId) {
+ if (mMsgHandler != null) {
+ mMsgHandler.reportResult(evalCrashInfo(intent));
+ return Service.START_NOT_STICKY;
+ }
+
+ // We don't want to do anything, this handler only exists
+ // so we produce a crash dump which is picked up by the
+ // test harness.
+ System.exit(0);
+ return Service.START_NOT_STICKY;
+ }
+
+ @Override
+ public synchronized IBinder onBind(Intent intent) {
+ mMsgHandler = new MessageHandler();
+ mMessenger = new Messenger(mMsgHandler);
+ return mMessenger.getBinder();
+ }
+
+ @Override
+ public synchronized boolean onUnbind(Intent intent) {
+ mMsgHandler = null;
+ mMessenger = null;
+ return false;
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRunnerActivity.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRunnerActivity.java
new file mode 100644
index 0000000000..6b8a80fb7b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRunnerActivity.java
@@ -0,0 +1,407 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test;
+
+import org.mozilla.geckoview.AllowOrDeny;
+import org.mozilla.geckoview.ContentBlocking;
+import org.mozilla.geckoview.GeckoDisplay;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.GeckoSessionSettings;
+import org.mozilla.geckoview.GeckoView;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoRuntimeSettings;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.WebExtensionController;
+import org.mozilla.geckoview.WebRequestError;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.SurfaceTexture;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.view.Surface;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+public class TestRunnerActivity extends Activity {
+ private static final String LOGTAG = "TestRunnerActivity";
+ private static final String ERROR_PAGE =
+ "<!DOCTYPE html><head><title>Error</title></head><body>Error!</body></html>";
+
+ static GeckoRuntime sRuntime;
+
+ private GeckoSession mPopupSession;
+ private GeckoSession mSession;
+ private GeckoView mView;
+ private boolean mKillProcessOnDestroy;
+
+ private HashMap<GeckoSession, Display> mDisplays = new HashMap<>();
+ private List<WebExtension> mExtensions = new ArrayList<>();
+
+ private static class Display {
+ public final SurfaceTexture texture;
+ public final Surface surface;
+
+ private final int width;
+ private final int height;
+ private GeckoDisplay sessionDisplay;
+
+ public Display(final int width, final int height) {
+ this.width = width;
+ this.height = height;
+ texture = new SurfaceTexture(0);
+ texture.setDefaultBufferSize(width, height);
+ surface = new Surface(texture);
+ }
+
+ public void attach(final GeckoSession session) {
+ sessionDisplay = session.acquireDisplay();
+ sessionDisplay.surfaceChanged(surface, width, height);
+ }
+
+ public void release(final GeckoSession session) {
+ sessionDisplay.surfaceDestroyed();
+ session.releaseDisplay(sessionDisplay);
+ }
+ }
+
+ private static WebExtensionController webExtensionController() {
+ return sRuntime.getWebExtensionController();
+ }
+
+ // Keeps track of all sessions for this test runner. The top session in the deque is the
+ // current active session for extension purposes.
+ private ArrayDeque<GeckoSession> mOwnedSessions = new ArrayDeque<>();
+
+ private GeckoSession.PermissionDelegate mPermissionDelegate = new GeckoSession.PermissionDelegate() {
+ @Override
+ public void onContentPermissionRequest(@NonNull GeckoSession session, @Nullable String uri, int type, @NonNull Callback callback) {
+ callback.grant();
+ }
+
+ @Override
+ public void onAndroidPermissionsRequest(@NonNull GeckoSession session, @Nullable String[] permissions, @NonNull Callback callback) {
+ callback.grant();
+ }
+ };
+
+ private GeckoSession.NavigationDelegate mNavigationDelegate = new GeckoSession.NavigationDelegate() {
+ @Override
+ public void onLocationChange(GeckoSession session, String url) {
+ getActionBar().setSubtitle(url);
+ }
+
+ @Override
+ public GeckoResult<AllowOrDeny> onLoadRequest(GeckoSession session,
+ LoadRequest request) {
+ // Allow Gecko to load all URIs
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+
+ @Override
+ public GeckoResult<GeckoSession> onNewSession(GeckoSession session, String uri) {
+ webExtensionController().setTabActive(mOwnedSessions.peek(), false);
+ GeckoSession newSession = createBackgroundSession(session.getSettings(),
+ /* active */ true);
+ webExtensionController().setTabActive(newSession, true);
+ return GeckoResult.fromValue(newSession);
+ }
+
+ @Override
+ public GeckoResult<String> onLoadError(GeckoSession session, String uri, WebRequestError error) {
+
+ return GeckoResult.fromValue("data:text/html," + ERROR_PAGE);
+ }
+ };
+
+ private GeckoSession.ContentDelegate mContentDelegate = new GeckoSession.ContentDelegate() {
+ private void onContentProcessGone() {
+ if (System.getenv("MOZ_CRASHREPORTER_SHUTDOWN") != null) {
+ sRuntime.shutdown();
+ }
+ }
+
+ @Override
+ public void onCloseRequest(GeckoSession session) {
+ closeSession(session);
+ }
+
+ @Override
+ public void onCrash(GeckoSession session) {
+ onContentProcessGone();
+ }
+
+ @Override
+ public void onKill(GeckoSession session) {
+ onContentProcessGone();
+ }
+ };
+
+ private WebExtension.ActionDelegate mActionDelegate = new WebExtension.ActionDelegate() {
+ @Nullable
+ @Override
+ public GeckoResult<GeckoSession> onOpenPopup(@NonNull WebExtension extension,
+ @NonNull WebExtension.Action action) {
+ if (mPopupSession != null) {
+ mPopupSession.close();
+ }
+
+ mPopupSession = createBackgroundSession(null, /* active */ false);
+ mPopupSession.open(sRuntime);
+
+ return GeckoResult.fromValue(mPopupSession);
+ }
+ };
+
+ private WebExtension.SessionTabDelegate mSessionTabDelegate = new WebExtension.SessionTabDelegate() {
+ @NonNull
+ @Override
+ public GeckoResult<AllowOrDeny> onCloseTab(@Nullable WebExtension source,
+ @NonNull GeckoSession session) {
+ closeSession(session);
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+ @Override
+ public GeckoResult<AllowOrDeny> onUpdateTab(@NonNull WebExtension source,
+ @NonNull GeckoSession session,
+ @NonNull WebExtension.UpdateTabDetails updateDetails) {
+ if (updateDetails.active == Boolean.TRUE) {
+ // Move session to the top since it's now the active tab
+ mOwnedSessions.remove(session);
+ mOwnedSessions.addFirst(session);
+ }
+
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+ };
+
+ /**
+ * Creates a session and adds it to the owned sessions deque.
+ *
+ * @param active Whether this session is the "active" session for extension purposes.
+ * The active session always sit at the top of the owned sessions deque.
+ * @return the newly created session.
+ */
+ private GeckoSession createSession(boolean active) {
+ return createSession(null, active);
+ }
+
+ /**
+ * Creates a session and adds it to the owned sessions deque.
+ *
+ * @param settings settings for the newly created {@link GeckoSession}, could be null
+ * if no extra settings need to be added.
+ * @param active Whether this session is the "active" session for extension purposes.
+ * The active session always sit at the top of the owned sessions deque.
+ * @return the newly created session.
+ */
+ private GeckoSession createSession(GeckoSessionSettings settings, boolean active) {
+ if (settings == null) {
+ settings = new GeckoSessionSettings();
+ }
+
+ final GeckoSession session = new GeckoSession(settings);
+ session.setNavigationDelegate(mNavigationDelegate);
+ session.setContentDelegate(mContentDelegate);
+ session.setPermissionDelegate(mPermissionDelegate);
+
+ final WebExtension.SessionController sessionController =
+ session.getWebExtensionController();
+ for (final WebExtension extension : mExtensions) {
+ sessionController.setActionDelegate(extension, mActionDelegate);
+ sessionController.setTabDelegate(extension, mSessionTabDelegate);
+ }
+
+ if (active) {
+ mOwnedSessions.addFirst(session);
+ } else {
+ mOwnedSessions.addLast(session);
+ }
+ return session;
+ }
+
+ /**
+ * Creates a session with a display attached.
+ *
+ * @param settings settings for the newly created {@link GeckoSession}, could be null
+ * if no extra settings need to be added.
+ * @param active Whether this session is the "active" session for extension purposes.
+ * The active session always sit at the top of the owned sessions deque.
+ * @return the newly created session.
+ */
+ private GeckoSession createBackgroundSession(final GeckoSessionSettings settings, boolean active) {
+ final GeckoSession session = createSession(settings, active);
+
+ final Display display = new Display(mView.getWidth(), mView.getHeight());
+ display.attach(session);
+
+ mDisplays.put(session, display);
+
+ return session;
+ }
+
+ private void closeSession(GeckoSession session) {
+ if (session == mOwnedSessions.peek()) {
+ webExtensionController().setTabActive(session, false);
+ }
+ if (mDisplays.containsKey(session)) {
+ final Display display = mDisplays.remove(session);
+ display.release(session);
+ }
+ mOwnedSessions.remove(session);
+ session.close();
+ if (!mOwnedSessions.isEmpty()) {
+ // Pick the top session as the current active
+ webExtensionController().setTabActive(mOwnedSessions.peek(), true);
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Intent intent = getIntent();
+
+ if (sRuntime == null) {
+ final GeckoRuntimeSettings.Builder runtimeSettingsBuilder =
+ new GeckoRuntimeSettings.Builder();
+
+ // Mochitest and reftest encounter rounding errors if we have a
+ // a window.devicePixelRation like 3.625, so simplify that here.
+ runtimeSettingsBuilder
+ .arguments(new String[] { "-purgecaches" })
+ .displayDpiOverride(160)
+ .displayDensityOverride(1.0f)
+ .remoteDebuggingEnabled(true);
+
+ final Bundle extras = intent.getExtras();
+ if (extras != null) {
+ runtimeSettingsBuilder.extras(extras);
+ }
+
+ final ContentBlocking.SafeBrowsingProvider googleLegacy = ContentBlocking.SafeBrowsingProvider
+ .from(ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing-dummy/update")
+ .build();
+
+ final ContentBlocking.SafeBrowsingProvider google = ContentBlocking.SafeBrowsingProvider
+ .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing4-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing4-dummy/update")
+ .build();
+
+ runtimeSettingsBuilder
+ .consoleOutput(true)
+ .contentBlocking(new ContentBlocking.Settings.Builder()
+ .safeBrowsingProviders(google, googleLegacy)
+ .build())
+ .crashHandler(TestCrashHandler.class);
+
+ sRuntime = GeckoRuntime.create(this, runtimeSettingsBuilder.build());
+
+ webExtensionController().setDebuggerDelegate(new WebExtensionController.DebuggerDelegate() {
+ @Override
+ public void onExtensionListUpdated() {
+ refreshExtensionList();
+ }
+ });
+
+ sRuntime.setDelegate(() -> {
+ mKillProcessOnDestroy = true;
+ finish();
+ });
+ }
+
+ mSession = createSession(/* active */ true);
+ webExtensionController().setTabActive(mOwnedSessions.peek(), true);
+ mSession.open(sRuntime);
+
+ // If we were passed a URI in the Intent, open it
+ final Uri uri = intent.getData();
+ if (uri != null) {
+ mSession.loadUri(uri.toString());
+ }
+
+ mView = new GeckoView(this);
+ mView.setSession(mSession);
+ setContentView(mView);
+ }
+
+ private void refreshExtensionList() {
+ webExtensionController().list().accept(extensions -> {
+ mExtensions = extensions;
+ for (WebExtension extension : mExtensions) {
+ extension.setActionDelegate(mActionDelegate);
+ extension.setTabDelegate(new WebExtension.TabDelegate() {
+ @Override
+ public GeckoResult<GeckoSession> onNewTab(WebExtension source,
+ WebExtension.CreateTabDetails details) {
+ GeckoSessionSettings settings = null;
+ if (details.cookieStoreId != null) {
+ settings = new GeckoSessionSettings.Builder()
+ .contextId(details.cookieStoreId)
+ .build();
+ }
+
+ if (details.active == Boolean.TRUE) {
+ webExtensionController().setTabActive(mOwnedSessions.peek(), false);
+ }
+ GeckoSession newSession = createSession(
+ settings,
+ details.active == Boolean.TRUE);
+ return GeckoResult.fromValue(newSession);
+ }
+ });
+
+ extension.setBrowsingDataDelegate(new WebExtension.BrowsingDataDelegate() {
+ @Nullable
+ @Override
+ public GeckoResult<Settings> onGetSettings() {
+ final long types =
+ Type.CACHE |
+ Type.COOKIES |
+ Type.HISTORY |
+ Type.FORM_DATA |
+ Type.DOWNLOADS;
+ return GeckoResult.fromValue(new Settings(1234, types, types ));
+ }
+ });
+
+ for (final GeckoSession session : mOwnedSessions) {
+ final WebExtension.SessionController controller =
+ session.getWebExtensionController();
+ controller.setActionDelegate(extension, mActionDelegate);
+ controller.setTabDelegate(extension, mSessionTabDelegate);
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onDestroy() {
+ mSession.close();
+ super.onDestroy();
+
+ if (mKillProcessOnDestroy) {
+ android.os.Process.killProcess(android.os.Process.myPid());
+ }
+ }
+
+ public GeckoView getGeckoView() {
+ return mView;
+ }
+
+ public GeckoSession getGeckoSession() {
+ return mSession;
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt
new file mode 100644
index 0000000000..e58fba8426
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt
@@ -0,0 +1,926 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.os.SystemClock
+import androidx.test.platform.app.InstrumentationRegistry
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+import androidx.test.filters.MediumTest
+import android.text.InputType;
+import android.view.KeyEvent
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.ExtractedTextRequest
+import android.view.inputmethod.InputConnection
+
+import org.hamcrest.Matchers.*
+import org.junit.Assume.assumeThat
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.junit.runners.Parameterized.Parameter
+import org.mozilla.geckoview.test.util.UiThreadUtils
+import java.util.*
+import java.util.concurrent.atomic.AtomicBoolean
+
+@MediumTest
+@RunWith(Parameterized::class)
+class TextInputDelegateTest : BaseSessionTest() {
+ // "parameters" needs to be a static field, so it has to be in a companion object.
+ companion object {
+ @get:Parameterized.Parameters(name = "{0}")
+ @JvmStatic
+ val parameters: List<Array<out Any>> = listOf(
+ arrayOf("#input"),
+ arrayOf("#textarea"),
+ arrayOf("#contenteditable"),
+ arrayOf("#designmode"))
+ }
+
+ @field:Parameter(0) @JvmField var id: String = ""
+
+ private var textContent: String
+ get() = when (id) {
+ "#contenteditable" -> mainSession.evaluateJS("document.querySelector('$id').textContent")
+ "#designmode" -> mainSession.evaluateJS("document.querySelector('$id').contentDocument.body.textContent")
+ else -> mainSession.evaluateJS("document.querySelector('$id').value")
+ } as String
+ set(content) {
+ when (id) {
+ "#contenteditable" -> mainSession.evaluateJS("document.querySelector('$id').textContent = '$content'")
+ "#designmode" -> mainSession.evaluateJS(
+ "document.querySelector('$id').contentDocument.body.textContent = '$content'")
+ else -> mainSession.evaluateJS("document.querySelector('$id').value = '$content'")
+ }
+ }
+
+ private var selectionOffsets: Pair<Int, Int>
+ get() = when (id) {
+ "#contenteditable" -> mainSession.evaluateJS("""[
+ document.getSelection().anchorOffset,
+ document.getSelection().focusOffset]""")
+ "#designmode" -> mainSession.evaluateJS("""(function() {
+ var sel = document.querySelector('$id').contentDocument.getSelection();
+ var text = document.querySelector('$id').contentDocument.body.firstChild;
+ return [sel.anchorOffset, sel.focusOffset];
+ })()""")
+ else -> mainSession.evaluateJS("""(document.querySelector('$id').selectionDirection !== 'backward'
+ ? [ document.querySelector('$id').selectionStart, document.querySelector('$id').selectionEnd ]
+ : [ document.querySelector('$id').selectionEnd, document.querySelector('$id').selectionStart ])""")
+ }.asJsonArray().let {
+ Pair(it.getInt(0), it.getInt(1))
+ }
+ set(offsets) {
+ var (start, end) = offsets
+ when (id) {
+ "#contenteditable" -> mainSession.evaluateJS("""(function() {
+ let selection = document.getSelection();
+ let text = document.querySelector('$id').firstChild;
+ if (text) {
+ selection.setBaseAndExtent(text, $start, text, $end)
+ } else {
+ selection.collapse(document.querySelector('$id'), 0);
+ }
+ })()""")
+ "#designmode" -> mainSession.evaluateJS("""(function() {
+ let selection = document.querySelector('$id').contentDocument.getSelection();
+ let text = document.querySelector('$id').contentDocument.body.firstChild;
+ if (text) {
+ selection.setBaseAndExtent(text, $start, text, $end)
+ } else {
+ selection.collapse(document.querySelector('$id').contentDocument.body, 0);
+ }
+ })()""")
+ else -> mainSession.evaluateJS("document.querySelector('$id').setSelectionRange($start, $end)")
+ }
+ }
+
+ private fun processParentEvents() {
+ sessionRule.requestedLocales
+ }
+
+ private fun processChildEvents() {
+ mainSession.waitForJS("new Promise(r => requestAnimationFrame(r))")
+ }
+
+ private fun setComposingText(ic: InputConnection, text: CharSequence, newCursorPosition: Int) {
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('compositionupdate', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('compositionupdate', r, { once: true }))"
+ })
+ ic.setComposingText(text, newCursorPosition)
+ promise.value
+ }
+
+ private fun finishComposingText(ic: InputConnection) {
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('compositionend', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('compositionend', r, { once: true }))"
+ })
+ ic.finishComposingText()
+ promise.value
+ }
+
+ private fun commitText(ic: InputConnection, text: CharSequence, newCursorPosition: Int) {
+ if (text == "") {
+ // No composition event is fired
+ ic.commitText(text, newCursorPosition)
+ return
+ }
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('compositionend', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('compositionend', r, { once: true }))"
+ })
+ ic.commitText(text, newCursorPosition)
+ promise.value
+ }
+
+ private fun deleteSurroundingText(ic: InputConnection, before: Int, after: Int) {
+ // deleteSurroundingText might fire multiple events.
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('input', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('input', r, { once: true }))"
+ })
+ ic.deleteSurroundingText(before, after)
+ if (before != 0 || after != 0) {
+ promise.value
+ }
+ // XXX: No way to wait for all events.
+ processChildEvents()
+ }
+
+ private fun setSelection(ic: InputConnection, start: Int, end: Int) {
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('selectionchange', r, { once: true }))"
+ "#contenteditable" -> "new Promise(r => document.addEventListener('selectionchange', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('selectionchange', r, { once: true }))"
+ })
+ ic.setSelection(start, end)
+ promise.value
+ }
+
+ private fun pressKey(ic: InputConnection, keyCode: Int) {
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('keyup', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('keyup', r, { once: true }))"
+ })
+ val time = SystemClock.uptimeMillis()
+ val keyEvent = KeyEvent(time, time, KeyEvent.ACTION_DOWN, keyCode, 0)
+ ic.sendKeyEvent(keyEvent)
+ ic.sendKeyEvent(KeyEvent.changeAction(keyEvent, KeyEvent.ACTION_UP))
+ promise.value
+ }
+
+ private fun syncShadowText(ic: InputConnection) {
+ // Workaround for sync shadow text
+ ic.beginBatchEdit()
+ ic.endBatchEdit()
+ }
+
+ @Test fun restartInput() {
+ // Check that restartInput is called on focus and blur.
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+ mainSession.waitUntilCalled(object : Callbacks.TextInputDelegate {
+ @AssertCalled(count = 1)
+ override fun restartInput(session: GeckoSession, reason: Int) {
+ assertThat("Reason should be correct",
+ reason, equalTo(GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS))
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('$id').blur()")
+ mainSession.waitUntilCalled(object : Callbacks.TextInputDelegate {
+ @AssertCalled(count = 1)
+ override fun restartInput(session: GeckoSession, reason: Int) {
+ assertThat("Reason should be correct",
+ reason, equalTo(GeckoSession.TextInputDelegate.RESTART_REASON_BLUR))
+ }
+
+ // Also check that showSoftInput/hideSoftInput are not called before a user action.
+ @AssertCalled(count = 0)
+ override fun showSoftInput(session: GeckoSession) {
+ }
+
+ @AssertCalled(count = 0)
+ override fun hideSoftInput(session: GeckoSession) {
+ }
+ })
+ }
+
+ @Test fun restartInput_temporaryFocus() {
+ // Our user action trick doesn't work for design-mode, so we can't test that here.
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+ // Disable for frequent failures Bug 1542525
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(false))
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ // Focus the input once here and once below, but we should only get a
+ // single restartInput or showSoftInput call for the second focus.
+ mainSession.evaluateJS("document.querySelector('$id').focus(); document.querySelector('$id').blur()")
+
+ // Simulate a user action so we're allowed to show/hide the keyboard.
+ mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT)
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+
+ mainSession.waitUntilCalled(object : Callbacks.TextInputDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun restartInput(session: GeckoSession, reason: Int) {
+ assertThat("Reason should be correct",
+ reason, equalTo(GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun showSoftInput(session: GeckoSession) {
+ super.showSoftInput(session)
+ }
+
+ @AssertCalled(count = 0)
+ override fun hideSoftInput(session: GeckoSession) {
+ super.hideSoftInput(session)
+ }
+ })
+ }
+
+ @Test fun restartInput_temporaryBlur() {
+ // Our user action trick doesn't work for design-mode, so we can't test that here.
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ // Simulate a user action so we're allowed to show/hide the keyboard.
+ mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT)
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class,
+ "restartInput", "showSoftInput")
+
+ // We should get a pair of restartInput calls for the blur/focus,
+ // but only one showSoftInput call and no hideSoftInput call.
+ mainSession.evaluateJS("document.querySelector('$id').blur(); document.querySelector('$id').focus()")
+
+ mainSession.waitUntilCalled(object : Callbacks.TextInputDelegate {
+ @AssertCalled(count = 2, order = [1])
+ override fun restartInput(session: GeckoSession, reason: Int) {
+ assertThat("Reason should be correct", reason, equalTo(forEachCall(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR,
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS)))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun showSoftInput(session: GeckoSession) {
+ }
+
+ @AssertCalled(count = 0)
+ override fun hideSoftInput(session: GeckoSession) {
+ }
+ })
+ }
+
+ @Test fun showHideSoftInput() {
+ // Our user action trick doesn't work for design-mode, so we can't test that here.
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ // Simulate a user action so we're allowed to show/hide the keyboard.
+ mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT)
+
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+ mainSession.waitUntilCalled(object : Callbacks.TextInputDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun restartInput(session: GeckoSession, reason: Int) {
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun showSoftInput(session: GeckoSession) {
+ }
+
+ @AssertCalled(count = 0)
+ override fun hideSoftInput(session: GeckoSession) {
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('$id').blur()")
+ mainSession.waitUntilCalled(object : Callbacks.TextInputDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun restartInput(session: GeckoSession, reason: Int) {
+ }
+
+ @AssertCalled(count = 0)
+ override fun showSoftInput(session: GeckoSession) {
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun hideSoftInput(session: GeckoSession) {
+ }
+ })
+ }
+
+ private fun getText(ic: InputConnection) =
+ ic.getExtractedText(ExtractedTextRequest(), 0).text.toString()
+
+ private fun assertText(message: String, actual: String, expected: String) =
+ // In an HTML editor, Gecko may insert an additional element that show up as a
+ // return character at the end. Deal with that here.
+ assertThat(message, actual.trimEnd('\n'), equalTo(expected))
+
+ private fun assertText(message: String, ic: InputConnection, expected: String,
+ checkGecko: Boolean = true) {
+ processChildEvents()
+ processParentEvents()
+
+ if (checkGecko) {
+ assertText(message, textContent, expected)
+ }
+ assertText(message, getText(ic), expected)
+ }
+
+ private fun assertSelection(message: String, ic: InputConnection, start: Int, end: Int,
+ checkGecko: Boolean = true) {
+ processChildEvents()
+ processParentEvents()
+
+ if (checkGecko) {
+ assertThat(message, selectionOffsets, equalTo(Pair(start, end)))
+ }
+
+ val extracted = ic.getExtractedText(ExtractedTextRequest(), 0)
+ assertThat(message, extracted.selectionStart, equalTo(start))
+ assertThat(message, extracted.selectionEnd, equalTo(end))
+ }
+
+ private fun assertSelectionAt(message: String, ic: InputConnection, value: Int,
+ checkGecko: Boolean = true) =
+ assertSelection(message, ic, value, value, checkGecko)
+
+ private fun assertTextAndSelection(message: String, ic: InputConnection,
+ expected: String, start: Int, end: Int,
+ checkGecko: Boolean = true) {
+ processChildEvents()
+ processParentEvents()
+
+ if (checkGecko) {
+ assertText(message, textContent, expected)
+ assertThat(message, selectionOffsets, equalTo(Pair(start, end)))
+ }
+
+ val extracted = ic.getExtractedText(ExtractedTextRequest(), 0)
+ assertText(message, extracted.text.toString(), expected)
+ assertThat(message, extracted.selectionStart, equalTo(start))
+ assertThat(message, extracted.selectionEnd, equalTo(end))
+ }
+
+ private fun assertTextAndSelectionAt(message: String, ic: InputConnection,
+ expected: String, value: Int,
+ checkGecko: Boolean = true) =
+ assertTextAndSelection(message, ic, expected, value, value, checkGecko)
+
+ private fun setupContent(content: String) {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "dom.select_events.textcontrols.enabled" to true))
+
+ mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ textContent = content
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+ }
+
+ // Test setSelection
+ @Ignore // Disable for frequent timeout for selection event.
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_setSelection() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ // TODO:
+ // onselectionchange won't be fired if caret is last. But commitText
+ // can set text and selection well (Bug 1360388).
+ commitText(ic, "foo", 1) // Selection at end of new text
+ assertTextAndSelectionAt("Can commit text", ic, "foo", 3)
+
+ setSelection(ic, 0, 3)
+ assertSelection("Can set selection to range", ic, 0, 3)
+ // No selection change event is fired
+ ic.setSelection(-3, 6)
+ // Test both forms of assert
+ assertTextAndSelection("Can handle invalid range", ic,
+ "foo", 0, 3)
+ setSelection(ic, 3, 3)
+ assertSelectionAt("Can collapse selection", ic, 3)
+ // No selection change event is fired
+ ic.setSelection(4, 4)
+ assertTextAndSelectionAt("Can handle invalid cursor", ic, "foo", 3)
+ }
+
+ // Test commitText
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_commitText() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ commitText(ic, "foo", 1) // Selection at end of new text
+ assertTextAndSelectionAt("Can commit empty text", ic, "foo", 3)
+
+ commitText(ic, "", 10) // Selection past end of new text
+ assertTextAndSelectionAt("Can commit empty text", ic, "foo", 3)
+ commitText(ic, "bar", 1) // Selection at end of new text
+ assertTextAndSelectionAt("Can commit text (select after)", ic,
+ "foobar", 6)
+ commitText(ic, "foo", -1) // Selection at start of new text
+ assertTextAndSelectionAt("Can commit text (select before)", ic,
+ "foobarfoo", 5, /* checkGecko */ false)
+ }
+
+ // Test deleteSurroundingText
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_deleteSurroundingText() {
+ setupContent("foobarfoo")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "foobarfoo")
+
+ setSelection(ic, 5, 5)
+ assertSelection("Can set selection to range", ic, 5, 5)
+
+ deleteSurroundingText(ic, 1, 0)
+ assertTextAndSelectionAt("Can delete text before", ic,
+ "foobrfoo", 4)
+ deleteSurroundingText(ic, 1, 1)
+ assertTextAndSelectionAt("Can delete text before/after", ic,
+ "foofoo", 3)
+ deleteSurroundingText(ic, 0, 10)
+ assertTextAndSelectionAt("Can delete text after", ic, "foo", 3)
+ deleteSurroundingText(ic, 0, 0)
+ assertTextAndSelectionAt("Can delete empty text", ic, "foo", 3)
+ }
+
+ // Test setComposingText
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_setComposingText() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ commitText(ic, "foo", 1) // Selection at end of new text
+ assertTextAndSelectionAt("Can commit text", ic, "foo", 3)
+
+ setComposingText(ic, "foo", 1)
+ assertTextAndSelectionAt("Can start composition", ic, "foofoo", 6)
+ setComposingText(ic, "", 1)
+ assertTextAndSelectionAt("Can set empty composition", ic, "foo", 3)
+ setComposingText(ic, "bar", 1)
+ assertTextAndSelectionAt("Can update composition", ic, "foobar", 6)
+
+ // Test finishComposingText
+ finishComposingText(ic)
+ assertTextAndSelectionAt("Can finish composition", ic, "foobar", 6)
+ }
+
+ // Test setComposingRegion
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_setComposingRegion() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ commitText(ic, "foobar", 1) // Selection at end of new text
+ assertTextAndSelectionAt("Can commit text", ic, "foobar", 6)
+
+ ic.setComposingRegion(0, 3)
+ assertTextAndSelectionAt("Can set composing region", ic, "foobar", 6)
+
+ setComposingText(ic, "far", 1)
+ assertTextAndSelectionAt("Can set composing region text", ic,
+ "farbar", 3)
+
+ ic.setComposingRegion(1, 4)
+ assertTextAndSelectionAt("Can set existing composing region", ic,
+ "farbar", 3)
+
+ setComposingText(ic, "rab", 3)
+ assertTextAndSelectionAt("Can set new composing region text", ic,
+ "frabar", 6, /* checkGecko */ false)
+
+ finishComposingText(ic)
+ }
+
+ // Test getTextBefore/AfterCursor
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_getTextBeforeAfterCursor() {
+ setupContent("foobar")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "foobar")
+
+ setSelection(ic, 3, 3)
+ assertSelection("Can set selection to range", ic, 3, 3)
+
+ // Test getTextBeforeCursor
+ assertThat("Can retrieve text before cursor",
+ "foo", equalTo(ic.getTextBeforeCursor(3, 0)))
+
+ // Test getTextAfterCursor
+ assertThat("Can retrieve text after cursor",
+ "bar", equalTo(ic.getTextAfterCursor(3, 0)))
+ }
+
+ // Test sendKeyEvent
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_sendKeyEvent() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ commitText(ic, "frabar", 1) // Selection at end of new text
+ assertTextAndSelectionAt("Can commit text", ic, "frabar", 6)
+
+ val time = SystemClock.uptimeMillis()
+ val shiftKey = KeyEvent(time, time, KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_SHIFT_LEFT, 0)
+
+ // Wait for selection change
+ var promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('selectionchange', r, { once: true }))"
+ "#contenteditable" -> "new Promise(r => document.addEventListener('selectionchange', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('selectionchange', r, { once: true }))"
+ })
+
+ ic.sendKeyEvent(shiftKey)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP))
+ promise.value
+ assertTextAndSelection("Can select using key event", ic,
+ "frabar", 6, 5)
+
+ promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('input', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('input', r, { once: true }))"
+ })
+
+ pressKey(ic, KeyEvent.KEYCODE_T)
+ promise.value
+ assertText("Can type using event", ic, "frabat")
+ }
+
+ // Test for Multiple setComposingText with same string length.
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_multiple_setComposingText() {
+
+ setupContent("")
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+
+ // Don't wait composition event for this test.
+ ic.setComposingText("aaa", 1)
+ ic.setComposingText("aaa", 1)
+ ic.setComposingText("aab", 1)
+
+ finishComposingText(ic)
+ assertTextAndSelectionAt("Multiple setComposingText don't commit composition string",
+ ic, "aab", 3)
+ }
+
+ // Bug 1133802, duplication when setting the same composing text more than once.
+ @Ignore // Disable for frequent failures.
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_bug1133802() {
+ // TODO:
+ // Disable this test for frequent failures. We consider another way to
+ // wait/ignore event handling.
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ setComposingText(ic, "foo", 1)
+ assertTextAndSelectionAt("Can set the composing text", ic, "foo", 3)
+ // Setting same text doesn't fire compositionupdate
+ ic.setComposingText("foo", 1)
+ assertTextAndSelectionAt("Can set the same composing text", ic,
+ "foo", 3)
+ setComposingText(ic, "bar", 1)
+ assertTextAndSelectionAt("Can set different composing text", ic,
+ "bar", 3)
+ // Setting same text doesn't fire compositionupdate
+ ic.setComposingText("bar", 1)
+ assertTextAndSelectionAt("Can set the same composing text", ic,
+ "bar", 3)
+ // Setting same text doesn't fire compositionupdate
+ ic.setComposingText("bar", 1)
+ assertTextAndSelectionAt("Can set the same composing text again", ic,
+ "bar", 3)
+ finishComposingText(ic)
+ assertTextAndSelectionAt("Can finish composing text", ic, "bar", 3)
+ }
+
+ // Bug 1209465, cannot enter ideographic space character by itself (U+3000).
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_bug1209465() {
+ // The ideographic space char may trigger font fallback; we don't want that to be async,
+ // as the resulting deferred reflow may confuse a following test.
+ sessionRule.setPrefsUntilTestEnd(mapOf("gfx.font_rendering.fallback.async" to false))
+
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ commitText(ic, "\u3000", 1)
+ assertTextAndSelectionAt("Can commit ideographic space", ic,
+ "\u3000", 1)
+ }
+
+ // Bug 1275371 - shift+backspace should not forward delete on Android.
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_bug1275371() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ ic.beginBatchEdit()
+ commitText(ic, "foo", 1)
+ setSelection(ic, 1, 1)
+ ic.endBatchEdit()
+ assertTextAndSelectionAt("Can commit text", ic, "foo", 1)
+
+ val time = SystemClock.uptimeMillis()
+ val shiftKey = KeyEvent(time, time, KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_SHIFT_LEFT, 0)
+ ic.sendKeyEvent(shiftKey)
+
+ // Wait for input change
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('input', r, { once: true }))"
+ else -> "new Promise(r => document.querySelector('$id').addEventListener('input', r, { once: true }))"
+ })
+
+ pressKey(ic, KeyEvent.KEYCODE_DEL)
+ promise.value
+ assertText("Can backspace with shift+backspace", ic, "oo")
+
+ pressKey(ic, KeyEvent.KEYCODE_DEL)
+ ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP))
+ assertTextAndSelectionAt("Cannot forward delete with shift+backspace", ic,
+ "oo", 0)
+ }
+
+ // Bug 1490391 - Committing then setting composition can result in duplicates.
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_bug1490391() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set initial text", ic, "")
+
+ commitText(ic, "far", 1)
+ setComposingText(ic, "bar", 1)
+ assertTextAndSelectionAt("Can commit then set composition", ic,
+ "farbar", 6)
+ setComposingText(ic, "baz", 1)
+ assertTextAndSelectionAt("Composition still exists after setting", ic,
+ "farbaz", 6)
+
+ finishComposingText(ic)
+
+ // TODO:
+ // Call ic.deleteSurroundingText(6, 0) and check result.
+ // Actually, no way to wait deleteSurroudingText since this may fire
+ // multiple events.
+ }
+
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun sendDummpyKeyboardEvent() {
+ // unnecessary for designmode
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ textContent = ""
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ ic.commitText("a", 1)
+
+ // Dispatching keydown, input and keyup
+ val promise =
+ mainSession.evaluatePromiseJS("""
+ new Promise(r => window.addEventListener('keydown', () => {
+ window.addEventListener('input',() => {
+ window.addEventListener('keyup', r, { once: true }) },
+ { once: true }) },
+ { once: true}))""")
+ ic.beginBatchEdit();
+ ic.setSelection(0, 1)
+ ic.setComposingText("", 1)
+ ic.endBatchEdit()
+ promise.value
+ assertText("empty text", ic, "")
+ }
+
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun editorInfo_default() {
+ mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ textContent = ""
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+
+ val editorInfo = EditorInfo()
+ mainSession.textInput.onCreateInputConnection(editorInfo)
+ assertThat("Default EditorInfo.inputType", editorInfo.inputType, equalTo(
+ when (id) {
+ "#input" -> InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or
+ InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE
+ else -> InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or
+ InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE
+ }))
+ }
+
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun editorInfo_enterKeyHint() {
+ // no way to set enterkeyhint on designmode.
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.forms.enterkeyhint" to true))
+
+ mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ textContent = ""
+ val values = listOf("enter", "done", "go", "previous", "next", "search", "send")
+ for (enterkeyhint in values) {
+ mainSession.evaluateJS("""
+ document.querySelector('$id').enterKeyHint = '$enterkeyhint';
+ document.querySelector('$id').focus()""")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+
+ val editorInfo = EditorInfo()
+ mainSession.textInput.onCreateInputConnection(editorInfo)
+ assertThat("EditorInfo.imeOptions by $enterkeyhint", editorInfo.imeOptions and EditorInfo.IME_MASK_ACTION, equalTo(
+ when (enterkeyhint) {
+ "done" -> EditorInfo.IME_ACTION_DONE
+ "go" -> EditorInfo.IME_ACTION_GO
+ "next" -> EditorInfo.IME_ACTION_NEXT
+ "previous" -> EditorInfo.IME_ACTION_PREVIOUS
+ "search" -> EditorInfo.IME_ACTION_SEARCH
+ "send" -> EditorInfo.IME_ACTION_SEND
+ else -> EditorInfo.IME_ACTION_NONE
+ }))
+
+ mainSession.evaluateJS("document.querySelector('$id').blur()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+ }
+ }
+
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun editorInfo_autocapitalize() {
+ // no way to set autocapitalize on designmode.
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.forms.autocapitalize" to true))
+
+ mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ textContent = ""
+ val values = listOf("characters", "none", "sentences", "words", "off", "on")
+ for (autocapitalize in values) {
+ mainSession.evaluateJS("""
+ document.querySelector('$id').autocapitalize = '$autocapitalize';
+ document.querySelector('$id').focus()""")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+
+ val editorInfo = EditorInfo()
+ mainSession.textInput.onCreateInputConnection(editorInfo)
+ assertThat("EditorInfo.inputType by $autocapitalize", editorInfo.inputType and 0x00007000, equalTo(
+ when (autocapitalize) {
+ "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
+ "on" -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
+ "sentences" -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
+ "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
+ else -> 0
+ }))
+
+ mainSession.evaluateJS("document.querySelector('$id').blur()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+ }
+ }
+
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun bug1613804_finishComposingText() {
+ mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ mainSession.loadTestPath(INPUTS_PATH)
+ mainSession.waitForPageStop()
+
+ textContent = ""
+ mainSession.evaluateJS("document.querySelector('$id').focus()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+
+ ic.beginBatchEdit();
+ ic.setComposingText("abc", 1)
+ ic.endBatchEdit()
+
+ // finishComposingText has to dispatch compositionend event.
+ finishComposingText(ic)
+
+ assertText("commit abc", ic, "abc")
+ }
+
+ // Bug 1593683 - Cursor is jumping when using the arrow keys in input field on GBoard
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_bug1593683() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+
+ setComposingText(ic, "foo", 1)
+ assertTextAndSelectionAt("Can set the composing text", ic, "foo", 3)
+ // Arrow key should keep composition then move caret
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ assertSelection("IME caret is moved to top", ic, 0, 0, /* checkGecko */ false)
+
+ setComposingText(ic, "bar", 1)
+ finishComposingText(ic)
+ assertText("commit abc", ic, "bar")
+ }
+
+ @WithDisplay(width = 512, height = 512) // Child process updates require having a display.
+ @Test fun inputConnection_bug1633621() {
+ // no way on designmode.
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ setupContent("")
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+
+ mainSession.evaluateJS("""
+ document.querySelector('$id').addEventListener('input', () => {
+ document.querySelector('$id').blur();
+ document.querySelector('$id').focus();
+ })
+ """)
+
+ setComposingText(ic, "b", 1)
+ assertTextAndSelectionAt("Don't change caret position after calling blur and focus",
+ ic, "b", 1)
+
+ setComposingText(ic, "a", 1)
+ assertTextAndSelectionAt("Can set composition string after calling blur and focus",
+ ic, "ba", 2)
+
+ pressKey(ic, KeyEvent.KEYCODE_R)
+ assertText("Can set input string by keypress after calling blur and focus",
+ ic, "bar")
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt
new file mode 100644
index 0000000000..c2a4284669
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt
@@ -0,0 +1,81 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.graphics.*
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.notNullValue
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import android.graphics.Bitmap
+import org.hamcrest.Matchers
+import org.hamcrest.Matchers.equalTo
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.util.Callbacks
+
+
+private const val SCREEN_HEIGHT = 800
+private const val SCREEN_WIDTH = 800
+private const val BANNER_HEIGHT = SCREEN_HEIGHT * 0.1f // height: 10%
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class VerticalClippingTest : BaseSessionTest() {
+ private fun getComparisonScreenshot(bottomOffset: Int): Bitmap {
+ val screenshotFile = Bitmap.createBitmap(SCREEN_WIDTH, SCREEN_HEIGHT, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(screenshotFile)
+ val paint = Paint()
+
+ // Draw body
+ paint.color = Color.rgb(0, 0, 255)
+ canvas.drawRect(0f, 0f, SCREEN_WIDTH.toFloat(), SCREEN_HEIGHT.toFloat(), paint)
+
+ // Draw bottom banner
+ paint.color = Color.rgb(0, 255, 0)
+ canvas.drawRect(0f, SCREEN_HEIGHT - BANNER_HEIGHT - bottomOffset,
+ SCREEN_WIDTH.toFloat(), (SCREEN_HEIGHT - bottomOffset).toFloat(), paint)
+
+ return screenshotFile
+ }
+
+ private fun assertScreenshotResult(result: GeckoResult<Bitmap>, comparisonImage: Bitmap) {
+ sessionRule.waitForResult(result).let {
+ assertThat("Screenshot is not null",
+ it, notNullValue())
+ assertThat("Widths are the same", comparisonImage.width, equalTo(it.width))
+ assertThat("Heights are the same", comparisonImage.height, equalTo(it.height))
+ assertThat("Byte counts are the same", comparisonImage.byteCount, equalTo(it.byteCount))
+ assertThat("Configs are the same", comparisonImage.config, equalTo(it.config))
+ assertThat("Images are almost identical",
+ ScreenshotTest.Companion.imageElementDifference(comparisonImage, it),
+ Matchers.lessThanOrEqualTo(1))
+ }
+ }
+
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun verticalClippingSucceeds() {
+ // Disable failing test on Webrender. Bug 1670267
+ assumeThat(sessionRule.env.isWebrender, equalTo(false))
+ sessionRule.display?.setVerticalClipping(45)
+ sessionRule.session.loadTestPath(FIXED_BOTTOM)
+ sessionRule.waitUntilCalled(object : Callbacks.ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), getComparisonScreenshot(45))
+ }
+ }
+
+} \ No newline at end of file
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt
new file mode 100644
index 0000000000..61cb5e1699
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt
@@ -0,0 +1,449 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.os.Build
+import android.os.SystemClock
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.ExpectedException
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoWebExecutor
+import org.mozilla.geckoview.WebRequest
+import org.mozilla.geckoview.WebRequestError
+import org.mozilla.geckoview.WebResponse
+import org.mozilla.geckoview.test.util.RuntimeCreator
+import org.mozilla.geckoview.test.util.TestServer
+import java.io.IOException
+import java.lang.IllegalStateException
+import java.math.BigInteger
+import java.net.UnknownHostException
+import java.nio.ByteBuffer
+import java.nio.charset.Charset
+import java.security.MessageDigest
+import java.util.*
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class WebExecutorTest {
+ companion object {
+ const val TEST_PORT: Int = 4242
+ const val TEST_ENDPOINT: String = "http://localhost:${TEST_PORT}"
+ }
+
+ lateinit var executor: GeckoWebExecutor
+ lateinit var server: TestServer
+
+ @get:Rule val thrown = ExpectedException.none()
+
+ @Before
+ fun setup() {
+ // Using @UiThreadTest here does not seem to block
+ // the tests which are not using @UiThreadTest, so we do that
+ // ourselves here as GeckoRuntime needs to be initialized
+ // on the UI thread.
+ runBlocking(Dispatchers.Main) {
+ executor = GeckoWebExecutor(RuntimeCreator.getRuntime())
+ }
+
+ server = TestServer(InstrumentationRegistry.getInstrumentation().targetContext)
+ server.start(TEST_PORT)
+ }
+
+ @After
+ fun cleanup() {
+ server.stop()
+ }
+
+ private fun fetch(request: WebRequest): WebResponse {
+ return fetch(request, GeckoWebExecutor.FETCH_FLAGS_NONE)
+ }
+
+ private fun fetch(request: WebRequest, flags: Int): WebResponse {
+ return executor.fetch(request, flags).pollDefault()!!
+ }
+
+ fun WebResponse.getBodyBytes(): ByteBuffer {
+ body!!.use {
+ return ByteBuffer.wrap(it.readBytes())
+ }
+ }
+
+ fun WebResponse.getJSONBody(): JSONObject {
+ val bytes = this.getBodyBytes()
+ val bodyString = Charset.forName("UTF-8").decode(bytes).toString()
+ return JSONObject(bodyString)
+ }
+
+ private fun randomString(count: Int): String {
+ val chars = "01234567890abcdefghijklmnopqrstuvwxyz[],./?;'"
+ val builder = StringBuilder(count)
+ val rand = Random(System.currentTimeMillis())
+
+ for (i in 0 until count) {
+ builder.append(chars[rand.nextInt(chars.length)])
+ }
+
+ return builder.toString()
+ }
+
+ @Test
+ fun smoke() {
+ val uri = "$TEST_ENDPOINT/anything"
+ val bodyString = randomString(8192)
+ val referrer = "http://foo/bar"
+
+ val request = WebRequest.Builder(uri)
+ .method("POST")
+ .header("Header1", "Clobbered")
+ .header("Header1", "Value")
+ .addHeader("Header2", "Value1")
+ .addHeader("Header2", "Value2")
+ .referrer(referrer)
+ .header("Content-Type", "text/plain")
+ .body(bodyString)
+ .build()
+
+ val response = fetch(request)
+
+ assertThat("URI should match", response.uri, equalTo(uri))
+ assertThat("Status could should match", response.statusCode, equalTo(200))
+ assertThat("Content type should match", response.headers["Content-Type"], equalTo("application/json; charset=utf-8"))
+ assertThat("Redirected should match", response.redirected, equalTo(false))
+ assertThat("isSecure should match", response.isSecure, equalTo(false))
+
+ val body = response.getJSONBody()
+ assertThat("Method should match", body.getString("method"), equalTo("POST"))
+ assertThat("Headers should match", body.getJSONObject("headers").getString("Header1"), equalTo("Value"))
+ assertThat("Headers should match", body.getJSONObject("headers").getString("Header2"), equalTo("Value1, Value2"))
+ assertThat("Headers should match", body.getJSONObject("headers").getString("Content-Type"), equalTo("text/plain"))
+ assertThat("Referrer should match", body.getJSONObject("headers").getString("Referer"), equalTo(referrer))
+ assertThat("Data should match", body.getString("data"), equalTo(bodyString));
+ }
+
+ @Test
+ fun testFetchAsset() {
+ val response = fetch(WebRequest("$TEST_ENDPOINT/assets/www/hello.html"))
+ assertThat("Status should match", response.statusCode, equalTo(200))
+ assertThat("Body should have bytes", response.getBodyBytes().remaining(), greaterThan(0))
+ }
+
+ @Test
+ fun testStatus() {
+ val response = fetch(WebRequest("$TEST_ENDPOINT/status/500"))
+ assertThat("Status code should match", response.statusCode, equalTo(500))
+ }
+
+ @Test
+ fun testRedirect() {
+ val response = fetch(WebRequest("$TEST_ENDPOINT/redirect-to?url=/status/200"))
+
+ assertThat("URI should match", response.uri, equalTo(TEST_ENDPOINT +"/status/200"))
+ assertThat("Redirected should match", response.redirected, equalTo(true))
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+ }
+
+ @Test
+ fun testDisallowRedirect() {
+ val response = fetch(WebRequest("$TEST_ENDPOINT/redirect-to?url=/status/200"), GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS)
+
+ assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/redirect-to?url=/status/200"))
+ assertThat("Redirected should match", response.redirected, equalTo(false))
+ assertThat("Status code should match", response.statusCode, equalTo(302))
+ }
+
+ @Test
+ fun testRedirectLoop() {
+ thrown.expect(equalTo(WebRequestError(WebRequestError.ERROR_REDIRECT_LOOP, WebRequestError.ERROR_CATEGORY_NETWORK)))
+ fetch(WebRequest("$TEST_ENDPOINT/redirect/100"))
+ }
+
+ @Test
+ fun testAuth() {
+ // We don't support authentication yet, but want to make sure it doesn't do anything
+ // silly like try to prompt the user.
+ val response = fetch(WebRequest("$TEST_ENDPOINT/basic-auth/foo/bar"))
+ assertThat("Status code should match", response.statusCode, equalTo(401))
+ }
+
+ @Test
+ fun testSslError() {
+ val uri = if (env.isAutomation) {
+ "https://expired.example.com/"
+ } else {
+ "https://expired.badssl.com/"
+ }
+
+ try {
+ fetch(WebRequest(uri))
+ throw IllegalStateException("fetch() should have thrown")
+ } catch (e: WebRequestError) {
+ assertThat("Category should match", e.category, equalTo(WebRequestError.ERROR_CATEGORY_SECURITY))
+ assertThat("Code should match", e.code, equalTo(WebRequestError.ERROR_SECURITY_BAD_CERT))
+ assertThat("Certificate should be present", e.certificate, notNullValue())
+ assertThat("Certificate issuer should be present", e.certificate?.issuerX500Principal?.name, not(isEmptyOrNullString()))
+ }
+ }
+
+ @Test
+ fun testSecure() {
+ val response = fetch(WebRequest("https://example.com"))
+ assertThat("Status should match", response.statusCode, equalTo(200))
+ assertThat("isSecure should match", response.isSecure, equalTo(true))
+
+ val expectedSubject = if (env.isAutomation)
+ "CN=example.com"
+ else
+ "CN=www.example.org,OU=Technology,O=Internet Corporation for Assigned Names and Numbers,L=Los Angeles,ST=California,C=US"
+
+ val expectedIssuer = if (env.isAutomation)
+ "OU=Profile Guided Optimization,O=Mozilla Testing,CN=Temporary Certificate Authority"
+ else
+ "CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US"
+
+ assertThat("Subject should match",
+ response.certificate?.subjectX500Principal?.name,
+ equalTo(expectedSubject))
+ assertThat("Issuer should match",
+ response.certificate?.issuerX500Principal?.name,
+ equalTo(expectedIssuer))
+ }
+
+ @Test
+ fun testCookies() {
+ val uptimeMillis = SystemClock.uptimeMillis()
+ val response = fetch(WebRequest("$TEST_ENDPOINT/cookies/set/uptimeMillis/$uptimeMillis"))
+
+ // We get redirected to /cookies which returns the cookies that were sent in the request
+ assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/cookies"))
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+
+ val body = response.getJSONBody()
+ assertThat("Body should match",
+ body.getJSONObject("cookies").getString("uptimeMillis"),
+ equalTo(uptimeMillis.toString()))
+
+ val anotherBody = fetch(WebRequest("$TEST_ENDPOINT/cookies")).getJSONBody()
+ assertThat("Body should match",
+ anotherBody.getJSONObject("cookies").getString("uptimeMillis"),
+ equalTo(uptimeMillis.toString()))
+ }
+
+ @Test
+ fun testAnonymousSendCookies() {
+ val uptimeMillis = SystemClock.uptimeMillis()
+ val response = fetch(WebRequest("$TEST_ENDPOINT/cookies/set/uptimeMillis/$uptimeMillis"), GeckoWebExecutor.FETCH_FLAGS_ANONYMOUS)
+
+ // We get redirected to /cookies which returns the cookies that were sent in the request
+ assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/cookies"))
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+
+ val body = response.getJSONBody()
+ assertThat("Cookies should not be set for the test server",
+ body.getJSONObject("cookies").length(),
+ equalTo(0))
+ }
+
+ @Test
+ fun testAnonymousGetCookies() {
+ // Ensure a cookie is set for the test server
+ testCookies()
+
+ val response = fetch(WebRequest("$TEST_ENDPOINT/cookies"),
+ GeckoWebExecutor.FETCH_FLAGS_ANONYMOUS)
+
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+ val cookies = response.getJSONBody().getJSONObject("cookies")
+ assertThat("Cookies should be empty", cookies.length(), equalTo(0))
+ }
+
+ @Test
+ fun testPrivateCookies() {
+ val uptimeMillis = SystemClock.uptimeMillis()
+ val response = fetch(WebRequest("$TEST_ENDPOINT/cookies/set/uptimeMillis/$uptimeMillis"), GeckoWebExecutor.FETCH_FLAGS_PRIVATE)
+
+ // We get redirected to /cookies which returns the cookies that were sent in the request
+ assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/cookies"))
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+
+ val body = response.getJSONBody()
+ assertThat("Cookies should be set for the test server",
+ body.getJSONObject("cookies").getString("uptimeMillis"),
+ equalTo(uptimeMillis.toString()))
+
+ val anotherBody = fetch(WebRequest("$TEST_ENDPOINT/cookies"), GeckoWebExecutor.FETCH_FLAGS_PRIVATE).getJSONBody()
+ assertThat("Body should match",
+ anotherBody.getJSONObject("cookies").getString("uptimeMillis"),
+ equalTo(uptimeMillis.toString()))
+
+ val yetAnotherBody = fetch(WebRequest("$TEST_ENDPOINT/cookies")).getJSONBody()
+ assertThat("Cookies set in private session are not supposed to be seen in normal download",
+ yetAnotherBody.getJSONObject("cookies").length(),
+ equalTo(0))
+ }
+
+ @Test
+ fun testSpeculativeConnect() {
+ // We don't have a way to know if it succeeds or not, but at least we can ensure
+ // it doesn't explode.
+ executor.speculativeConnect("http://localhost")
+
+ // This is just a fence to ensure the above actually ran.
+ fetch(WebRequest("$TEST_ENDPOINT/cookies"))
+ }
+
+ @Test
+ fun testResolveV4() {
+ val addresses = executor.resolve("localhost").pollDefault()!!
+ assertThat("Addresses should not be null",
+ addresses, notNullValue())
+ assertThat("First address should be loopback",
+ addresses.first().isLoopbackAddress, equalTo(true))
+ assertThat("First address size should be 4",
+ addresses.first().address.size, equalTo(4))
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.LOLLIPOP)
+ fun testResolveV6() {
+ val addresses = executor.resolve("ip6-localhost").pollDefault()!!
+ assertThat("Addresses should not be null",
+ addresses, notNullValue())
+ assertThat("First address should be loopback",
+ addresses.first().isLoopbackAddress, equalTo(true))
+ assertThat("First address size should be 16",
+ addresses.first().address.size, equalTo(16))
+ }
+
+ @Test
+ fun testFetchUnknownHost() {
+ thrown.expect(equalTo(WebRequestError(WebRequestError.ERROR_UNKNOWN_HOST, WebRequestError.ERROR_CATEGORY_URI)))
+ fetch(WebRequest("https://this.should.not.resolve"))
+ }
+
+ @Test(expected = UnknownHostException::class)
+ fun testResolveError() {
+ executor.resolve("this.should.not.resolve").pollDefault()
+ }
+
+ @Test
+ fun testFetchStream() {
+ val expectedCount = 1 * 1024 * 1024 // 1MB
+ val response = executor.fetch(WebRequest("$TEST_ENDPOINT/bytes/$expectedCount")).pollDefault()!!
+
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+ assertThat("Content-Length should match", response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount))
+
+ val stream = response.body!!
+ val bytes = stream.readBytes()
+ stream.close()
+
+ assertThat("Byte counts should match", bytes.size, equalTo(expectedCount))
+
+ val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
+ assertThat("Hashes should match", response.headers["X-SHA-256"],
+ equalTo(String.format("%064x", BigInteger(1, digest))))
+ }
+
+ @Test(expected = IOException::class)
+ fun testFetchStreamError() {
+
+ val expectedCount = 1 * 1024 * 1024 // 1MB
+ val response = executor.fetch(WebRequest("$TEST_ENDPOINT/bytes/$expectedCount"),
+ GeckoWebExecutor.FETCH_FLAGS_STREAM_FAILURE_TEST).pollDefault()!!
+
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+ assertThat("Content-Length should match",response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount))
+
+ val stream = response.body!!
+ val bytes = ByteArray(1)
+ stream.read(bytes)
+ }
+
+ @Test(expected = IOException::class)
+ fun readClosedStream() {
+ val response = executor.fetch(WebRequest("$TEST_ENDPOINT/bytes/1024")).pollDefault()!!
+
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+
+ val stream = response.body!!
+ stream.close()
+ stream.readBytes()
+ }
+
+ @Test(expected = IOException::class)
+ fun readTimeout() {
+ val expectedCount = 10
+ val response = executor.fetch(WebRequest("$TEST_ENDPOINT/trickle/${expectedCount}")).pollDefault()!!
+
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+ assertThat("Content-Length should match", response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount))
+
+ // Only allow 1ms of blocking. This should reliably timeout with 1MB of data.
+ response.setReadTimeoutMillis(1)
+
+ val stream = response.body!!
+ stream.readBytes()
+ }
+
+ @Test
+ fun testFetchStreamCancel() {
+ val expectedCount = 1 * 1024 * 1024 // 1MB
+ val response = executor.fetch(WebRequest("$TEST_ENDPOINT/bytes/$expectedCount")).pollDefault()!!
+
+ assertThat("Status code should match", response.statusCode, equalTo(200))
+ assertThat("Content-Length should match", response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount))
+
+ val stream = response.body!!;
+
+ assertThat("Stream should have 0 bytes available", stream.available(), equalTo(0))
+
+ // Wait a second. Not perfect, but should be enough time for at least one buffer
+ // to be appended if things are not going as they should.
+ SystemClock.sleep(1000);
+
+ assertThat("Stream should still have 0 bytes available", stream.available(), equalTo(0));
+
+ stream.close()
+ }
+
+ @Test
+ fun unsupportedUriScheme() {
+ val illegal = mapOf(
+ "" to "",
+ "a" to "a",
+ "ab" to "ab",
+ "abc" to "abc",
+ "htt" to "htt",
+ "123456789" to "123456789",
+ "1234567890" to "1234567890",
+ "12345678901" to "1234567890",
+ "file://test" to "file://tes",
+ "moz-extension://what" to "moz-extens"
+ )
+
+ for ((uri, truncated) in illegal) {
+ try {
+ fetch(WebRequest(uri))
+ throw IllegalStateException("fetch() should have thrown")
+ } catch (e: IllegalArgumentException) {
+ assertThat("Message should match",
+ e.message,
+ equalTo("Unsupported URI scheme: $truncated"))
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt
new file mode 100644
index 0000000000..39c1aa9dcf
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt
@@ -0,0 +1,2294 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.core.IsEqual.equalTo
+import org.hamcrest.core.StringEndsWith.endsWith
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.WebExtension.*
+import org.mozilla.geckoview.WebExtension.BrowsingDataDelegate.Type.*
+import org.mozilla.geckoview.WebExtensionController.EnableSource
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.Setting
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.test.util.RuntimeCreator
+import org.mozilla.geckoview.test.util.UiThreadUtils
+import java.nio.charset.Charset
+import java.util.*
+import java.util.concurrent.CancellationException
+import kotlin.collections.HashMap
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class WebExtensionTest : BaseSessionTest() {
+ companion object {
+ private const val TABS_CREATE_BACKGROUND: String =
+ "resource://android/assets/web_extensions/tabs-create/"
+ private const val TABS_CREATE_2_BACKGROUND: String =
+ "resource://android/assets/web_extensions/tabs-create-2/"
+ private const val TABS_CREATE_REMOVE_BACKGROUND: String =
+ "resource://android/assets/web_extensions/tabs-create-remove/"
+ private const val TABS_ACTIVATE_REMOVE_BACKGROUND: String =
+ "resource://android/assets/web_extensions/tabs-activate-remove/"
+ private const val TABS_REMOVE_BACKGROUND: String =
+ "resource://android/assets/web_extensions/tabs-remove/"
+ private const val MESSAGING_BACKGROUND: String =
+ "resource://android/assets/web_extensions/messaging/"
+ private const val MESSAGING_CONTENT: String =
+ "resource://android/assets/web_extensions/messaging-content/"
+ private const val OPENOPTIONSPAGE_1_BACKGROUND: String =
+ "resource://android/assets/web_extensions/openoptionspage-1/"
+ private const val OPENOPTIONSPAGE_2_BACKGROUND: String =
+ "resource://android/assets/web_extensions/openoptionspage-2/"
+ private const val EXTENSION_PAGE_RESTORE: String =
+ "resource://android/assets/web_extensions/extension-page-restore/"
+ private const val BROWSING_DATA: String =
+ "resource://android/assets/web_extensions/browsing-data-built-in/"
+ }
+
+ private val controller
+ get() = sessionRule.runtime.webExtensionController
+
+ @Before
+ fun setup() {
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtensionController.PromptDelegate::class,
+ controller::setPromptDelegate,
+ { controller.promptDelegate = null },
+ object : WebExtensionController.PromptDelegate {}
+ )
+ sessionRule.setPrefsUntilTestEnd(mapOf("extensions.isembedded" to true))
+ sessionRule.runtime.webExtensionController.setTabActive(mainSession, true)
+ }
+
+ @Test
+ fun installBuiltIn() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ // Load the WebExtension that will add a border to the body
+ val borderify = sessionRule.waitForResult(controller.installBuiltIn(
+ "resource://android/assets/web_extensions/borderify/"
+ ))
+
+ assertTrue(borderify.isBuiltIn)
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was applied by checking the border color
+ assertBodyBorderEqualTo("red")
+
+ // Uninstall WebExtension and check again
+ sessionRule.waitForResult(controller.uninstall(borderify))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was not applied after being uninstalled
+ assertBodyBorderEqualTo("")
+ }
+
+ private fun assertBodyBorderEqualTo(expected: String) {
+ val color = mainSession.evaluateJS("document.body.style.borderColor")
+ assertThat("The border color should be '$expected'",
+ color as String, equalTo(expected))
+ }
+
+ private fun checkDisabledState(extension: WebExtension,
+ userDisabled: Boolean = false, appDisabled: Boolean = false,
+ blocklistDisabled: Boolean = false) {
+
+ val enabled = !userDisabled && !appDisabled && !blocklistDisabled
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ if (!enabled) {
+ // Border should be empty because the extension is disabled
+ assertBodyBorderEqualTo("")
+ } else {
+ assertBodyBorderEqualTo("red")
+ }
+
+ assertThat("enabled should match",
+ extension.metaData.enabled, equalTo(enabled))
+ assertThat("userDisabled should match",
+ extension.metaData.disabledFlags and DisabledFlags.USER > 0,
+ equalTo(userDisabled))
+ assertThat("appDisabled should match",
+ extension.metaData.disabledFlags and DisabledFlags.APP > 0,
+ equalTo(appDisabled))
+ assertThat("blocklistDisabled should match",
+ extension.metaData.disabledFlags and DisabledFlags.BLOCKLIST > 0,
+ equalTo(blocklistDisabled))
+ }
+
+ @Test
+ fun noDelegateErrorMessage() {
+ try {
+ sessionRule.evaluateExtensionJS("""
+ const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
+ await browser.tabs.update(tab.id, { url: "www.google.com" });
+ """)
+ assertThat("tabs.update should not succeed", true, equalTo(false))
+ } catch (ex: RejectedPromiseException) {
+ assertThat("Error message matches", ex.message,
+ equalTo("Error: tabs.update is not supported"))
+ }
+
+ try {
+ sessionRule.evaluateExtensionJS("""
+ const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
+ await browser.tabs.remove(tab.id);
+ """)
+ assertThat("tabs.remove should not succeed", true, equalTo(false))
+ } catch (ex: RejectedPromiseException) {
+ assertThat("Error message matches", ex.message,
+ equalTo("Error: tabs.remove is not supported"))
+ }
+
+ try {
+ sessionRule.evaluateExtensionJS("""
+ await browser.runtime.openOptionsPage();
+ """)
+ assertThat("runtime.openOptionsPage should not succeed",
+ true, equalTo(false))
+ } catch (ex: RejectedPromiseException) {
+ assertThat("Error message matches", ex.message,
+ equalTo("Error: runtime.openOptionsPage is not supported"))
+ }
+ }
+
+ @Test
+ fun enableDisable() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ var borderify = sessionRule.waitForResult(
+ controller.install("resource://android/assets/web_extensions/borderify.xpi"))
+ checkDisabledState(borderify, userDisabled=false, appDisabled=false)
+
+ borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.USER))
+ checkDisabledState(borderify, userDisabled=true, appDisabled=false)
+
+ borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.APP))
+ checkDisabledState(borderify, userDisabled=true, appDisabled=true)
+
+ borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.APP))
+ checkDisabledState(borderify, userDisabled=true, appDisabled=false)
+
+ borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.USER))
+ checkDisabledState(borderify, userDisabled=false, appDisabled=false)
+
+ borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.APP))
+ checkDisabledState(borderify, userDisabled=false, appDisabled=true)
+
+ borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.APP))
+ checkDisabledState(borderify, userDisabled=false, appDisabled=false)
+
+ sessionRule.waitForResult(controller.uninstall(borderify))
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Border should be empty because the extension is not installed anymore
+ assertBodyBorderEqualTo("")
+ }
+
+ @Test
+ fun installWebExtension() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.metaData.description,
+ "Adds a red border to all webpages matching example.com.")
+ assertEquals(extension.metaData.name, "Borderify")
+ assertEquals(extension.metaData.version, "1.0")
+ assertEquals(extension.isBuiltIn, false)
+ assertEquals(extension.metaData.enabled, false)
+ assertEquals(extension.metaData.signedState,
+ WebExtension.SignedStateFlags.SIGNED)
+ assertEquals(extension.metaData.blocklistState,
+ WebExtension.BlocklistStateFlags.NOT_BLOCKED)
+
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val borderify = sessionRule.waitForResult(
+ controller.install("resource://android/assets/web_extensions/borderify.xpi"))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was applied by checking the border color
+ assertBodyBorderEqualTo("red")
+
+ var list = extensionsMap(sessionRule.waitForResult(controller.list()))
+ assertEquals(list.size, 2)
+ assertTrue(list.containsKey(borderify.id))
+ assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID))
+
+ // Uninstall WebExtension and check again
+ sessionRule.waitForResult(controller.uninstall(borderify))
+
+ list = extensionsMap(sessionRule.waitForResult(controller.list()))
+ assertEquals(list.size, 1)
+ assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was not applied after being uninstalled
+ assertBodyBorderEqualTo("")
+ }
+
+ @Test
+ @Setting.List(Setting(key = Setting.Key.USE_PRIVATE_MODE, value = "true"))
+ fun runInPrivateBrowsing() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // Make sure border is empty before running the extension
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled(count=1)
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ var borderify = sessionRule.waitForResult(
+ controller.install("resource://android/assets/web_extensions/borderify.xpi"))
+
+ // Make sure private mode is enabled
+ assertTrue(mainSession.settings.usePrivateMode)
+ assertFalse(borderify.metaData.allowedInPrivateBrowsing)
+ // Check that the WebExtension was not applied to a private mode page
+ assertBodyBorderEqualTo("")
+
+ borderify = sessionRule.waitForResult(
+ controller.setAllowedInPrivateBrowsing(borderify, true))
+
+ assertTrue(borderify.metaData.allowedInPrivateBrowsing)
+ // Check that the WebExtension was applied to a private mode page now that the extension
+ // is enabled in private mode
+ mainSession.reload();
+ sessionRule.waitForPageStop()
+ assertBodyBorderEqualTo("red")
+
+ borderify = sessionRule.waitForResult(
+ controller.setAllowedInPrivateBrowsing(borderify, false))
+
+ assertFalse(borderify.metaData.allowedInPrivateBrowsing)
+ // Check that the WebExtension was not applied to a private mode page after being
+ // not allowed to run in private mode
+ mainSession.reload();
+ sessionRule.waitForPageStop()
+ assertBodyBorderEqualTo("")
+
+ // Uninstall WebExtension and check again
+ sessionRule.waitForResult(controller.uninstall(borderify))
+ mainSession.reload();
+ sessionRule.waitForPageStop()
+ assertBodyBorderEqualTo("")
+ }
+
+ @Test
+ fun optionsPageMetadata() {
+ // dummy.xpi is not signed, but it could be
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false
+ ))
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled(count=1)
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val dummy = sessionRule.waitForResult(
+ controller.install("resource://android/assets/web_extensions/dummy.xpi"))
+
+ val metadata = dummy.metaData
+ assertTrue((metadata.optionsPageUrl ?: "").matches("^moz-extension://[0-9a-f\\-]*/options.html$".toRegex()));
+ assertEquals(metadata.openOptionsPageInTab, true);
+ assertTrue(metadata.baseUrl.matches("^moz-extension://[0-9a-f\\-]*/$".toRegex()))
+
+ sessionRule.waitForResult(controller.uninstall(dummy))
+ }
+
+ @Test
+ fun installMultiple() {
+ // dummy.xpi is not signed, but it could be
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false
+ ))
+
+ // First, make sure the list only contains the test support extension
+ var list = extensionsMap(sessionRule.waitForResult(controller.list()))
+ assertEquals(list.size, 1)
+ assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID))
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled(count=2)
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ // Install in parallell borderify and dummy
+ val borderifyResult = controller.install(
+ "resource://android/assets/web_extensions/borderify.xpi")
+ val dummyResult = controller.install(
+ "resource://android/assets/web_extensions/dummy.xpi")
+
+ val (borderify, dummy) = sessionRule.waitForResult(
+ GeckoResult.allOf(borderifyResult, dummyResult))
+
+ // Make sure the list is updated accordingly
+ list = extensionsMap(sessionRule.waitForResult(controller.list()))
+ assertTrue(list.containsKey(borderify.id))
+ assertTrue(list.containsKey(dummy.id))
+ assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID))
+ assertEquals(list.size, 3)
+
+ // Uninstall borderify and verify that it's not in the list anymore
+ sessionRule.waitForResult(controller.uninstall(borderify))
+
+ list = extensionsMap(sessionRule.waitForResult(controller.list()))
+ assertEquals(list.size, 2)
+ assertTrue(list.containsKey(dummy.id))
+ assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID))
+ assertFalse(list.containsKey(borderify.id))
+
+ // Uninstall dummy and make sure the list is now empty
+ sessionRule.waitForResult(controller.uninstall(dummy))
+
+ list = extensionsMap(sessionRule.waitForResult(controller.list()))
+ assertEquals(list.size, 1)
+ assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID))
+ }
+
+ private fun testInstallError(name: String, expectedError: Int) {
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled(count = 0)
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ sessionRule.waitForResult(
+ controller.install("resource://android/assets/web_extensions/$name")
+ .accept({
+ // We should not be able to install unsigned extensions
+ assertTrue(false)
+ }, { exception ->
+ val installException = exception as WebExtension.InstallException
+ assertEquals(installException.code, expectedError)
+ }))
+ }
+
+ private fun extensionsMap(extensionList: List<WebExtension>): Map<String, WebExtension> {
+ val map = HashMap<String, WebExtension>()
+ for (extension in extensionList) {
+ map.put(extension.id, extension);
+ }
+ return map
+ }
+
+ @Test
+ fun installUnsignedExtensionSignatureNotRequired() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false
+ ))
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val borderify = sessionRule.waitForResult(controller.install(
+ "resource://android/assets/web_extensions/borderify-unsigned.xpi")
+ .then { extension ->
+ assertEquals(extension!!.metaData.signedState,
+ WebExtension.SignedStateFlags.MISSING)
+ assertEquals(extension.metaData.blocklistState,
+ WebExtension.BlocklistStateFlags.NOT_BLOCKED)
+ assertEquals(extension.metaData.name, "Borderify")
+ GeckoResult.fromValue(extension)
+ })
+
+ sessionRule.waitForResult(controller.uninstall(borderify))
+ }
+
+ @Test
+ fun installUnsignedExtensionSignatureRequired() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to true
+ ))
+ testInstallError("borderify-unsigned.xpi",
+ WebExtension.InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED)
+ }
+
+ @Test
+ fun installExtensionFileNotFound() {
+ testInstallError("file-not-found.xpi",
+ WebExtension.InstallException.ErrorCodes.ERROR_NETWORK_FAILURE)
+ }
+
+ @Test
+ fun installExtensionMissingId() {
+ testInstallError("borderify-missing-id.xpi",
+ WebExtension.InstallException.ErrorCodes.ERROR_CORRUPT_FILE)
+ }
+
+ @Test
+ fun installDeny() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // Ensure border is empty to start.
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.DENY)
+ }
+ })
+
+ sessionRule.waitForResult(
+ controller.install("resource://android/assets/web_extensions/borderify.xpi").accept({
+ // We should not be able to install the extension.
+ assertTrue(false)
+ }, { exception ->
+ assertTrue(exception is WebExtension.InstallException)
+ val installException = exception as WebExtension.InstallException
+ assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED)
+ }));
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was not installed and the border is still empty.
+ assertBodyBorderEqualTo("")
+ }
+
+ @Test
+ fun createNotification() {
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebNotificationDelegate::class,
+ { delegate ->
+ sessionRule.runtime.webNotificationDelegate = delegate },
+ { sessionRule.runtime.webNotificationDelegate = null },
+ object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ }
+ })
+
+ val extension = sessionRule.waitForResult(
+ controller.installBuiltIn("resource://android/assets/web_extensions/notification-test/"))
+
+ sessionRule.waitUntilCalled(object : WebNotificationDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowNotification(notification: WebNotification) {
+ assertEquals(notification.title, "Time for cake!")
+ assertEquals(notification.text, "Something something cake")
+ assertEquals(notification.imageUrl, "http://example.com/img.svg")
+ // This should be filled out, Bug 1589693
+ assertEquals(notification.source, null)
+ }
+ })
+
+ sessionRule.waitForResult(
+ controller.uninstall(extension))
+ }
+
+ // This test
+ // - Registers a web extension
+ // - Listens for messages and waits for a message
+ // - Sends a response to the message and waits for a second message
+ // - Verify that the second message has the correct value
+ //
+ // When `background == true` the test will be run using background messaging, otherwise the
+ // test will use content script messaging.
+ private fun testOnMessage(background: Boolean) {
+ val messageResult = GeckoResult<Void>()
+
+ val prefix = if (background) "testBackground" else "testContent"
+
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ var awaitingResponse = false
+ var completed = false
+
+ override fun onConnect(port: WebExtension.Port) {
+ // Ignored for this test
+ }
+
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ checkSender(nativeApp, sender, background)
+
+ if (!awaitingResponse) {
+ assertThat("We should receive a message from the WebExtension", message as String,
+ equalTo("${prefix}BrowserMessage"))
+ awaitingResponse = true
+ return GeckoResult.fromValue("${prefix}MessageResponse")
+ } else if (!completed) {
+ assertThat("The background script should receive our message and respond",
+ message as String, equalTo("response: ${prefix}MessageResponse"))
+ messageResult.complete(null)
+ completed = true
+ }
+ return null
+ }
+ }
+
+ val messaging = installWebExtension(background, messageDelegate)
+ sessionRule.waitForResult(messageResult)
+
+ sessionRule.waitForResult(controller.uninstall(messaging))
+ }
+
+ // This test
+ // - Listen for a new tab request from a web extension
+ // - Registers a web extension
+ // - Waits for onNewTab request
+ // - Verify that request came from right extension
+ @Test
+ fun testBrowserTabsCreate() {
+ val tabsCreateResult = GeckoResult<Void>()
+ var tabsExtension: WebExtension? = null
+ val tabDelegate = object : WebExtension.TabDelegate {
+ override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> {
+ assertEquals(details.url, "https://www.mozilla.org/en-US/")
+ assertEquals(details.active, true)
+ assertEquals(tabsExtension!!, source)
+ tabsCreateResult.complete(null)
+ return GeckoResult.fromValue(null)
+ }
+ }
+
+ tabsExtension = sessionRule.waitForResult(controller.installBuiltIn(TABS_CREATE_BACKGROUND))
+ tabsExtension.setTabDelegate(tabDelegate)
+ sessionRule.waitForResult(tabsCreateResult)
+
+ sessionRule.waitForResult(controller.uninstall(tabsExtension))
+ }
+
+ // This test
+ // - Listen for a new tab request from a web extension
+ // - Registers a web extension
+ // - Extension requests creation of new tab with a cookie store id.
+ // - Waits for onNewTab request
+ // - Verify that request came from right extension
+ @Test
+ fun testBrowserTabsCreateWithCookieStoreId() {
+ val tabsCreateResult = GeckoResult<Void>()
+ var tabsExtension: WebExtension? = null
+ val tabDelegate = object : WebExtension.TabDelegate {
+ override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> {
+ assertEquals(details.url, "https://www.mozilla.org/en-US/")
+ assertEquals(details.active, true)
+ assertEquals(details.cookieStoreId, "1")
+ assertEquals(tabsExtension!!.id, source.id)
+ tabsCreateResult.complete(null)
+ return GeckoResult.fromValue(null)
+ }
+ }
+
+ tabsExtension = sessionRule.waitForResult(controller.installBuiltIn(TABS_CREATE_2_BACKGROUND))
+ tabsExtension.setTabDelegate(tabDelegate)
+ sessionRule.waitForResult(tabsCreateResult)
+
+ sessionRule.waitForResult(controller.uninstall(tabsExtension))
+ }
+
+ // This test
+ // - Create and assign WebExtension TabDelegate to handle creation and closing of tabs
+ // - Registers a WebExtension
+ // - Extension requests creation of new tab
+ // - TabDelegate handles creation of new tab
+ // - Extension requests removal of newly created tab
+ // - TabDelegate handles closing of newly created tab
+ // - Verify that close request came from right extension and targeted session
+ @Test
+ fun testBrowserTabsCreateBrowserTabsRemove() {
+ val onCloseRequestResult = GeckoResult<Void>()
+ val tabsExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(TABS_CREATE_REMOVE_BACKGROUND))
+
+ tabsExtension.tabDelegate = object : WebExtension.TabDelegate {
+ override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> {
+ val extensionCreatedSession = sessionRule.createClosedSession(sessionRule.session.settings)
+
+ extensionCreatedSession.webExtensionController.setTabDelegate(tabsExtension, object : WebExtension.SessionTabDelegate {
+ override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult<AllowOrDeny> {
+ assertEquals(tabsExtension.id, source!!.id)
+ assertEquals(details.active, true)
+ assertNotEquals(null, extensionCreatedSession)
+ assertEquals(extensionCreatedSession, session)
+ onCloseRequestResult.complete(null)
+ return GeckoResult.ALLOW
+ }
+ })
+
+ return GeckoResult.fromValue(extensionCreatedSession)
+ }
+ };
+
+ sessionRule.waitForResult(onCloseRequestResult)
+ sessionRule.waitForResult(controller.uninstall(tabsExtension))
+ }
+
+ // This test
+ // - Create and assign WebExtension TabDelegate to handle creation and closing of tabs
+ // - Create and opens a new GeckoSession
+ // - Set the main session as active tab
+ // - Registers a WebExtension
+ // - Extension listens for activated tab changes
+ // - Set the main session as inactive tab
+ // - Set the newly created GeckoSession as active tab
+ // - Extension requests removal of newly created tab if tabs.query({active: true})
+ // contains only the newly activated tab
+ // - TabDelegate handles closing of newly created tab
+ // - Verify that close request came from right extension and targeted session
+ @Test
+ fun testSetTabActive() {
+ val onCloseRequestResult = GeckoResult<Void>()
+ val tabsExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(TABS_ACTIVATE_REMOVE_BACKGROUND))
+ val newTabSession = sessionRule.createOpenSession(sessionRule.session.settings)
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtension.SessionTabDelegate::class,
+ { delegate -> newTabSession.webExtensionController.setTabDelegate(tabsExtension, delegate) },
+ { newTabSession.webExtensionController.setTabDelegate(tabsExtension, null) },
+ object : WebExtension.SessionTabDelegate {
+
+ override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult<AllowOrDeny> {
+ assertEquals(tabsExtension, source)
+ assertEquals(newTabSession, session)
+ onCloseRequestResult.complete(null)
+ return GeckoResult.ALLOW
+ }
+ })
+
+ controller.setTabActive(sessionRule.session, false)
+ controller.setTabActive(newTabSession, true)
+
+ sessionRule.waitForResult(onCloseRequestResult)
+ sessionRule.waitForResult(controller.uninstall(tabsExtension))
+ }
+
+ private fun browsingDataMessage(port: WebExtension.Port, type: String,
+ since: Long? = null): GeckoResult<JSONObject> {
+ val message = JSONObject("{" +
+ "\"type\": \"$type\"" +
+ "}")
+ if (since != null) {
+ message.put("since", since)
+ }
+ return browsingDataCall(port, message)
+ }
+
+ private fun browsingDataCall(port: WebExtension.Port,
+ json: JSONObject): GeckoResult<JSONObject> {
+ val uuid = UUID.randomUUID().toString()
+ json.put("uuid", uuid)
+ port.postMessage(json)
+
+ val response = GeckoResult<JSONObject>()
+ port.setDelegate(object : WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any, port: WebExtension.Port) {
+ assertThat("Response ID Matches.",
+ (message as JSONObject).getString("uuid"), equalTo(uuid))
+ response.complete(message)
+ }
+ })
+ return response
+ }
+
+ @Test
+ fun testBrowsingDataDelegateBuiltIn() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+
+ val extension = sessionRule.waitForResult(
+ controller.installBuiltIn(BROWSING_DATA))
+
+ val portResult = GeckoResult<WebExtension.Port>()
+ extension.setMessageDelegate(object : WebExtension.MessageDelegate {
+ override fun onConnect(port: WebExtension.Port) {
+ portResult.complete(port)
+ }
+ }, "browser")
+
+ val TEST_SINCE_VALUE = 59294;
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtension.BrowsingDataDelegate::class,
+ { delegate -> extension.browsingDataDelegate = delegate },
+ { extension.browsingDataDelegate = null },
+ object : WebExtension.BrowsingDataDelegate {
+ override fun onGetSettings(): GeckoResult<WebExtension.BrowsingDataDelegate.Settings>? {
+ return GeckoResult.fromValue(WebExtension.BrowsingDataDelegate.Settings(
+ TEST_SINCE_VALUE,
+ CACHE or COOKIES or DOWNLOADS or HISTORY or LOCAL_STORAGE,
+ CACHE or COOKIES or HISTORY
+ ))
+ }
+ })
+
+ val port = sessionRule.waitForResult(portResult)
+
+ // Test browsingData.removeDownloads
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearDownloads(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(1234L))
+ return null
+ }
+ })
+ sessionRule.waitForResult(browsingDataMessage(port, "clear-downloads", 1234))
+
+ // Test browsingData.removeFormData
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearFormData(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(1234L))
+ return null
+ }
+ })
+ sessionRule.waitForResult(browsingDataMessage(port,"clear-form-data", 1234))
+
+ // Test browsingData.removeHistory
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(1234L))
+ return null
+ }
+ })
+ sessionRule.waitForResult(browsingDataMessage(port, "clear-history", 1234))
+
+ // Test browsingData.removePasswords
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(1234L))
+ return null
+ }
+ })
+ sessionRule.waitForResult(browsingDataMessage(port, "clear-passwords", 1234))
+
+ // Test browsingData.remove({ indexedDB: true, localStorage: true, passwords: true })
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(0L))
+ return null
+ }
+ })
+ var response = sessionRule.waitForResult(browsingDataCall(port,
+ JSONObject("{" +
+ "\"type\": \"clear\"," +
+ "\"removalOptions\": {}," +
+ "\"dataTypes\": {\"indexedDB\": true, \"localStorage\": true, \"passwords\": true}" +
+ "}")))
+ assertThat("browsingData.remove should succeed",
+ response.getString("type"),
+ equalTo("response"))
+
+ // Test browsingData.remove({ indexedDB: true, history: true, passwords: true })
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(0L))
+ return null
+ }
+ @AssertCalled
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(0L))
+ return null
+ }
+ })
+ response = sessionRule.waitForResult(browsingDataCall(port,
+ JSONObject("{" +
+ "\"type\": \"clear\"," +
+ "\"removalOptions\": {}," +
+ "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" +
+ "}")))
+ assertThat("browsingData.remove should succeed",
+ response.getString("type"),
+ equalTo("response"))
+
+ // Test browsingData.remove({ indexedDB: true, history: true, passwords: true })
+ // with failure
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(0L))
+ return null
+ }
+ @AssertCalled
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(0L))
+ return GeckoResult.fromException(RuntimeException("Not authorized."));
+ }
+ })
+ response = sessionRule.waitForResult(browsingDataCall(port,
+ JSONObject("{" +
+ "\"type\": \"clear\"," +
+ "\"removalOptions\": {}," +
+ "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" +
+ "}")))
+ assertThat("browsingData.remove returns expected error.",
+ response.getString("error"),
+ equalTo("Not authorized."))
+
+ // Test browsingData.remove({ indexedDB: true, history: true, passwords: true })
+ // with multiple failures
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ @AssertCalled
+ override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(0L))
+ return GeckoResult.fromException(RuntimeException("Not authorized passwords."));
+ }
+ @AssertCalled
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ assertThat("timestamp should match", sinceUnixTimestamp,
+ equalTo(0L))
+ return GeckoResult.fromException(RuntimeException("Not authorized history."));
+ }
+ })
+ response = sessionRule.waitForResult(browsingDataCall(port,
+ JSONObject("{" +
+ "\"type\": \"clear\"," +
+ "\"removalOptions\": {}," +
+ "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" +
+ "}")))
+ val error = response.getString("error")
+ assertThat("browsingData.remove returns expected error.",
+ error == "Not authorized passwords." || error == "Not authorized history.",
+ equalTo(true))
+
+ // Test browsingData.settings()
+ response = sessionRule.waitForResult(
+ browsingDataMessage(port, "get-settings"))
+
+ val settings = response.getJSONObject("result")
+ val dataToRemove = settings.getJSONObject("dataToRemove")
+ val options = settings.getJSONObject("options")
+
+ assertThat("Since should be correct",
+ options.getInt("since"), equalTo(TEST_SINCE_VALUE))
+ for (key in listOf("cache", "cookies", "history")) {
+ assertThat("Data to remove should be correct",
+ dataToRemove.getBoolean(key), equalTo(true))
+ }
+ for (key in listOf("downloads", "localStorage")) {
+ assertThat("Data to remove should be correct",
+ dataToRemove.getBoolean(key), equalTo(false))
+ }
+
+ val dataRemovalPermitted = settings.getJSONObject("dataRemovalPermitted")
+ for (key in listOf("cache", "cookies", "downloads", "history", "localStorage")) {
+ assertThat("Data removal permitted should be correct",
+ dataRemovalPermitted.getBoolean(key), equalTo(true))
+ }
+
+ // Test browsingData.settings() with no delegate
+ sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate {
+ override fun onGetSettings(): GeckoResult<WebExtension.BrowsingDataDelegate.Settings>? {
+ return null
+ }
+ })
+ response = sessionRule.waitForResult(
+ browsingDataMessage(port, "get-settings"))
+ assertThat("browsingData.settings returns expected error.",
+ response.getString("error"),
+ equalTo("browsingData.settings is not supported"))
+
+ sessionRule.waitForResult(controller.uninstall(extension))
+ }
+
+ @Test
+ fun testBrowsingDataDelegate() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val extension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/browsing-data.xpi"))
+
+ val accumulator = mutableListOf<String>()
+ val result = GeckoResult<List<String>>()
+
+ extension.browsingDataDelegate = object : WebExtension.BrowsingDataDelegate {
+ fun register(type: String, timestamp: Long) {
+ accumulator.add("$type $timestamp")
+ if (accumulator.size >= 5) {
+ result.complete(accumulator)
+ }
+ }
+
+ override fun onClearDownloads(sinceUnixTimestamp: Long): GeckoResult<Void> {
+ register("downloads", sinceUnixTimestamp)
+ return GeckoResult.fromValue(null);
+ }
+
+ override fun onClearFormData(sinceUnixTimestamp: Long): GeckoResult<Void> {
+ register("formData", sinceUnixTimestamp)
+ return GeckoResult.fromValue(null);
+ }
+
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void> {
+ register("history", sinceUnixTimestamp)
+ return GeckoResult.fromValue(null);
+ }
+
+ override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void> {
+ register("passwords", sinceUnixTimestamp)
+ return GeckoResult.fromValue(null);
+ }
+ }
+
+ val actual = sessionRule.waitForResult(result)
+ assertThat("Delegate methods get called in the right order",
+ actual, equalTo(listOf(
+ "downloads 10001",
+ "formData 10002",
+ "history 10003",
+ "passwords 10004",
+ "downloads 10005"
+ )))
+
+ sessionRule.waitForResult(controller.uninstall(extension))
+ }
+
+ // Same as testSetTabActive when the extension is not allowed in private browsing
+ @Test
+ fun testSetTabActiveNotAllowedInPrivateBrowsing() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+
+ val onCloseRequestResult = GeckoResult<Void>()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+ val tabsExtension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/tabs-activate-remove.xpi"))
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+ var tabsExtensionPB = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/tabs-activate-remove-2.xpi"))
+
+ tabsExtensionPB = sessionRule.waitForResult(
+ controller.setAllowedInPrivateBrowsing(tabsExtensionPB, true))
+
+
+ val newTabSession = sessionRule.createOpenSession(sessionRule.session.settings)
+
+ val newPrivateSession = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder().usePrivateMode(true).build())
+
+ val privateBrowsingNewTabSession = GeckoResult<Void>()
+
+ class TabDelegate(val result: GeckoResult<Void>, val extension: WebExtension,
+ val expectedSession: GeckoSession)
+ : WebExtension.SessionTabDelegate {
+ override fun onCloseTab(source: WebExtension?,
+ session: GeckoSession): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.id, source!!.id)
+ assertEquals(expectedSession, session)
+ result.complete(null)
+ return GeckoResult.ALLOW
+ }
+ }
+
+ newTabSession.webExtensionController.setTabDelegate(tabsExtensionPB,
+ TabDelegate(privateBrowsingNewTabSession, tabsExtensionPB, newTabSession))
+
+ newTabSession.webExtensionController.setTabDelegate(tabsExtension,
+ TabDelegate(onCloseRequestResult, tabsExtension, newTabSession))
+
+ val privateBrowsingPrivateSession = GeckoResult<Void>()
+
+ newPrivateSession.webExtensionController.setTabDelegate(tabsExtensionPB,
+ TabDelegate(privateBrowsingPrivateSession, tabsExtensionPB, newPrivateSession))
+
+ // tabsExtension is not allowed in private browsing and shouldn't get this event
+ newPrivateSession.webExtensionController.setTabDelegate(tabsExtension,
+ object: WebExtension.SessionTabDelegate {
+ override fun onCloseTab(source: WebExtension?,
+ session: GeckoSession): GeckoResult<AllowOrDeny> {
+ privateBrowsingPrivateSession.completeExceptionally(
+ RuntimeException("Should never happen"))
+ return GeckoResult.ALLOW
+ }
+ })
+
+ controller.setTabActive(sessionRule.session, false)
+ controller.setTabActive(newPrivateSession, true)
+
+ sessionRule.waitForResult(privateBrowsingPrivateSession)
+
+ controller.setTabActive(newPrivateSession, false)
+ controller.setTabActive(newTabSession, true)
+
+ sessionRule.waitForResult(onCloseRequestResult)
+ sessionRule.waitForResult(privateBrowsingNewTabSession)
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.webExtensionController.uninstall(tabsExtension))
+ sessionRule.waitForResult(
+ sessionRule.runtime.webExtensionController.uninstall(tabsExtensionPB))
+
+ newTabSession.close()
+ newPrivateSession.close()
+ }
+
+ // Verifies that the following messages are received from an extension page loaded in the session
+ // - HELLO_FROM_PAGE_1 from nativeApp browser1
+ // - HELLO_FROM_PAGE_2 from nativeApp browser2
+ // - connection request from browser1
+ // - HELLO_FROM_PORT from the port opened at the above step
+ private fun testExtensionMessages(extension: WebExtension, session: GeckoSession) {
+ val messageResult2 = GeckoResult<String>()
+ session.webExtensionController.setMessageDelegate(
+ extension, object : WebExtension.MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ messageResult2.complete(message as String);
+ return null
+ }
+ }, "browser2")
+
+ val message2 = sessionRule.waitForResult(messageResult2)
+ assertThat("Message is received correctly", message2,
+ equalTo("HELLO_FROM_PAGE_2"))
+
+ val messageResult1 = GeckoResult<String>()
+ val portResult = GeckoResult<WebExtension.Port>()
+ session.webExtensionController.setMessageDelegate(
+ extension, object : WebExtension.MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ messageResult1.complete(message as String);
+ return null
+ }
+
+ override fun onConnect(port: WebExtension.Port) {
+ portResult.complete(port)
+ }
+ }, "browser1")
+
+ val message1 = sessionRule.waitForResult(messageResult1)
+ assertThat("Message is received correctly", message1,
+ equalTo("HELLO_FROM_PAGE_1"))
+
+ val port = sessionRule.waitForResult(portResult)
+ val portMessageResult = GeckoResult<String>()
+ port.setDelegate(object : WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any, port: WebExtension.Port) {
+ portMessageResult.complete(message as String)
+ }
+ })
+
+ val portMessage = sessionRule.waitForResult(portMessageResult)
+ assertThat("Message is received correctly", portMessage,
+ equalTo("HELLO_FROM_PORT"))
+ }
+
+ // This test:
+ // - loads an extension that tries to send some messages when loading tab.html
+ // - verifies that the messages are received when loading the tab normally
+ // - verifies that the messages are received when restoring the tab in a fresh session
+ @Test
+ fun testRestoringExtensionPagePreservesMessages() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ val extension = sessionRule.waitForResult(
+ controller.installBuiltIn(EXTENSION_PAGE_RESTORE))
+
+ sessionRule.session.loadUri("${extension.metaData.baseUrl}tab.html")
+ sessionRule.waitForPageStop()
+
+ var savedState : GeckoSession.SessionState? = null
+ sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+ @AssertCalled(count=1)
+ override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+ savedState = state
+ }
+ })
+
+ // Test that messages are received in the main session
+ testExtensionMessages(extension, sessionRule.session)
+
+ val newSession = sessionRule.createOpenSession()
+ newSession.restoreState(savedState!!)
+ newSession.waitForPageStop()
+
+ // Test that messages are received in a restored state
+ testExtensionMessages(extension, newSession)
+
+ sessionRule.waitForResult(controller.uninstall(extension))
+ }
+
+ // This test
+ // - Create and assign WebExtension TabDelegate to handle closing of tabs
+ // - Create new GeckoSession for WebExtension to close
+ // - Load url that will allow extension to identify the tab
+ // - Registers a WebExtension
+ // - Extension finds the tab by url and removes it
+ // - TabDelegate handles closing of the tab
+ // - Verify that request targets previously created GeckoSession
+ @Test
+ fun testBrowserTabsRemove() {
+ val onCloseRequestResult = GeckoResult<Void>()
+ val existingSession = sessionRule.createOpenSession()
+
+ existingSession.loadTestPath("$HELLO_HTML_PATH?tabToClose")
+ existingSession.waitForPageStop()
+
+ val tabsExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(TABS_REMOVE_BACKGROUND))
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtension.SessionTabDelegate::class,
+ { delegate -> existingSession.webExtensionController.setTabDelegate(tabsExtension, delegate) },
+ { existingSession.webExtensionController.setTabDelegate(tabsExtension, null) },
+ object : WebExtension.SessionTabDelegate {
+ override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult<AllowOrDeny> {
+ assertEquals(existingSession, session)
+ onCloseRequestResult.complete(null)
+ return GeckoResult.ALLOW
+ }
+ })
+
+ sessionRule.waitForResult(onCloseRequestResult)
+ sessionRule.waitForResult(controller.uninstall(tabsExtension))
+ }
+
+ private fun installWebExtension(background: Boolean,
+ messageDelegate: WebExtension.MessageDelegate): WebExtension {
+ val webExtension: WebExtension
+
+ if (background) {
+ webExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(MESSAGING_BACKGROUND))
+ webExtension.setMessageDelegate(messageDelegate, "browser")
+ } else {
+ webExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(MESSAGING_CONTENT))
+ sessionRule.session.webExtensionController
+ .setMessageDelegate(webExtension, messageDelegate, "browser")
+ }
+
+ return webExtension
+ }
+
+ @Test
+ fun contentMessaging() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+ testOnMessage(false)
+ }
+
+ @Test
+ fun backgroundMessaging() {
+ testOnMessage(true)
+ }
+
+ // This test
+ // - installs a web extension
+ // - waits for the web extension to connect to the browser
+ // - on connect it will start listening on the port for a message
+ // - When the message is received it sends a message in response and waits for another message
+ // - When the second message is received it verifies it contains the expected value
+ //
+ // When `background == true` the test will be run using background messaging, otherwise the
+ // test will use content script messaging.
+ private fun testPortMessage(background: Boolean) {
+ val result = GeckoResult<Void>()
+ val prefix = if (background) "testBackground" else "testContent"
+
+ val portDelegate = object: WebExtension.PortDelegate {
+ var awaitingResponse = false
+
+ override fun onPortMessage(message: Any, port: WebExtension.Port) {
+ assertEquals(port.name, "browser")
+
+ if (!awaitingResponse) {
+ assertThat("We should receive a message from the WebExtension",
+ message as String, equalTo("${prefix}PortMessage"))
+ port.postMessage(JSONObject("{\"message\": \"${prefix}PortMessageResponse\"}"))
+ awaitingResponse = true
+ } else {
+ assertThat("The background script should receive our message and respond",
+ message as String, equalTo("response: ${prefix}PortMessageResponse"))
+ result.complete(null)
+ }
+ }
+
+ override fun onDisconnect(port: WebExtension.Port) {
+ // ignored
+ }
+ }
+
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onConnect(port: WebExtension.Port) {
+ checkSender(port.name, port.sender, background)
+
+ assertEquals(port.name, "browser")
+
+ port.setDelegate(portDelegate)
+ }
+
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ // Ignored for this test
+ return null
+ }
+ }
+
+ val messaging = installWebExtension(background, messageDelegate)
+ sessionRule.waitForResult(result)
+ sessionRule.waitForResult(controller.uninstall(messaging))
+ }
+
+ @Test
+ fun contentPortMessaging() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+ testPortMessage(false)
+ }
+
+ @Test
+ fun backgroundPortMessaging() {
+ testPortMessage(true)
+ }
+
+ // This test
+ // - Registers a web extension
+ // - Awaits for the web extension to connect to the browser
+ // - When connected, it triggers a disconnection from the other side and verifies that
+ // the browser is notified of the port being disconnected.
+ //
+ // When `background == true` the test will be run using background messaging, otherwise the
+ // test will use content script messaging.
+ //
+ // When `refresh == true` the disconnection will be triggered by refreshing the page, otherwise
+ // it will be triggered by sending a message to the web extension.
+ private fun testPortDisconnect(background: Boolean, refresh: Boolean) {
+ val result = GeckoResult<Void>()
+
+ var messaging: WebExtension? = null
+ var messagingPort: WebExtension.Port? = null
+
+ val portDelegate = object: WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any,
+ port: WebExtension.Port) {
+ assertEquals(port, messagingPort)
+ }
+
+ override fun onDisconnect(port: WebExtension.Port) {
+ assertEquals(messaging!!.id, port.sender.webExtension.id)
+ assertEquals(port, messagingPort)
+ // We successfully received a disconnection
+ result.complete(null)
+ }
+ }
+
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onConnect(port: WebExtension.Port) {
+ assertEquals(messaging!!.id, port.sender.webExtension.id)
+ checkSender(port.name, port.sender, background)
+
+ assertEquals(port.name, "browser")
+ messagingPort = port
+ port.setDelegate(portDelegate)
+
+ if (refresh) {
+ // Refreshing the page should disconnect the port
+ sessionRule.session.reload()
+ } else {
+ // Let's ask the web extension to disconnect this port
+ val message = JSONObject()
+ message.put("action", "disconnect")
+
+ port.postMessage(message)
+ }
+ }
+
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ assertEquals(messaging!!.id, sender.webExtension.id)
+
+ // Ignored for this test
+ return null
+ }
+ }
+
+ messaging = installWebExtension(background, messageDelegate)
+ sessionRule.waitForResult(result)
+ sessionRule.waitForResult(controller.uninstall(messaging))
+ }
+
+ @Test
+ fun contentPortDisconnect() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+ testPortDisconnect(background=false, refresh=false)
+ }
+
+ @Test
+ fun backgroundPortDisconnect() {
+ testPortDisconnect(background=true, refresh=false)
+ }
+
+ @Test
+ fun contentPortDisconnectAfterRefresh() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+ testPortDisconnect(background=false, refresh=true)
+ }
+
+ fun checkSender(nativeApp: String, sender: WebExtension.MessageSender, background: Boolean) {
+ assertEquals("nativeApp should always be 'browser'", nativeApp, "browser")
+
+ if (background) {
+ // For background scripts we only want messages from the extension, this should never
+ // happen and it's a bug if we get here.
+ assertEquals("Called from content script with background-only delegate.",
+ sender.environmentType, WebExtension.MessageSender.ENV_TYPE_EXTENSION)
+ assertTrue("Unexpected sender url",
+ sender.url.endsWith("/_generated_background_page.html"))
+ } else {
+ assertEquals("Called from background script, expecting only content scripts",
+ sender.environmentType, WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT)
+ assertTrue("Expecting only top level senders.", sender.isTopLevel)
+ assertEquals("Unexpected sender url", sender.url, "http://example.com/")
+ }
+ }
+
+ // This test
+ // - Register a web extension and waits for connections
+ // - When connected it disconnects the port from the app side
+ // - Awaits for a message from the web extension confirming the web extension was notified of
+ // port being closed.
+ //
+ // When `background == true` the test will be run using background messaging, otherwise the
+ // test will use content script messaging.
+ private fun testPortDisconnectFromApp(background: Boolean) {
+ val result = GeckoResult<Void>()
+
+ var messaging: WebExtension? = null
+
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onConnect(port: WebExtension.Port) {
+ assertEquals(messaging!!.id, port.sender.webExtension.id)
+ checkSender(port.name, port.sender, background)
+
+ port.disconnect()
+ }
+
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ assertEquals(messaging!!.id, sender.webExtension.id)
+ checkSender(nativeApp, sender, background)
+
+ if (message is JSONObject) {
+ if (message.getString("type") == "portDisconnected") {
+ result.complete(null)
+ }
+ }
+
+ return null
+ }
+ }
+
+ messaging = installWebExtension(background, messageDelegate)
+ sessionRule.waitForResult(result)
+ sessionRule.waitForResult(controller.uninstall(messaging))
+ }
+
+ @Test
+ fun contentPortDisconnectFromApp() {
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+ testPortDisconnectFromApp(false)
+ }
+
+ @Test
+ fun backgroundPortDisconnectFromApp() {
+ testPortDisconnectFromApp(true)
+ }
+
+ // This test checks that scripts running in a iframe have the `isTopLevel` property set to false.
+ private fun testIframeTopLevel() {
+ val portTopLevel = GeckoResult<Void>()
+ val portIframe = GeckoResult<Void>()
+ val messageTopLevel = GeckoResult<Void>()
+ val messageIframe = GeckoResult<Void>()
+
+ var messaging: WebExtension? = null
+
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onConnect(port: WebExtension.Port) {
+ assertEquals(messaging!!.id, port.sender.webExtension.id)
+ assertEquals(WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT,
+ port.sender.environmentType)
+ when (port.sender.url) {
+ "$TEST_ENDPOINT$HELLO_IFRAME_HTML_PATH" -> {
+ assertTrue(port.sender.isTopLevel)
+ portTopLevel.complete(null)
+ }
+ "$TEST_ENDPOINT$HELLO_HTML_PATH" -> {
+ assertFalse(port.sender.isTopLevel)
+ portIframe.complete(null)
+ }
+ else -> // We shouldn't get other messages
+ fail()
+ }
+
+ port.disconnect()
+ }
+
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ assertEquals(messaging!!.id, sender.webExtension.id)
+ assertEquals(WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT,
+ sender.environmentType)
+ when (sender.url) {
+ "$TEST_ENDPOINT$HELLO_IFRAME_HTML_PATH" -> {
+ assertTrue(sender.isTopLevel)
+ messageTopLevel.complete(null)
+ }
+ "$TEST_ENDPOINT$HELLO_HTML_PATH" -> {
+ assertFalse(sender.isTopLevel)
+ messageIframe.complete(null)
+ }
+ else -> // We shouldn't get other messages
+ fail()
+ }
+
+ return null
+ }
+ }
+
+ messaging = sessionRule.waitForResult(controller.installBuiltIn(
+ "resource://android/assets/web_extensions/messaging-iframe/"))
+ sessionRule.session.webExtensionController
+ .setMessageDelegate(messaging, messageDelegate, "browser")
+ sessionRule.waitForResult(portTopLevel)
+ sessionRule.waitForResult(portIframe)
+ sessionRule.waitForResult(messageTopLevel)
+ sessionRule.waitForResult(messageIframe)
+ sessionRule.waitForResult(controller.uninstall(messaging))
+ }
+
+ @Test
+ fun iframeTopLevel() {
+ mainSession.loadTestPath(HELLO_IFRAME_HTML_PATH)
+ sessionRule.waitForPageStop()
+ testIframeTopLevel()
+ }
+
+ @Test
+ fun redirectToExtensionResource() {
+ val result = GeckoResult<String>()
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ assertEquals(message, "setupReadyStartTest")
+ result.complete(null)
+ return null
+ }
+ }
+
+ val extension = sessionRule.waitForResult(controller.installBuiltIn(
+ "resource://android/assets/web_extensions/redirect-to-android-resource/"))
+
+ extension.setMessageDelegate(messageDelegate, "browser")
+ sessionRule.waitForResult(result)
+
+ // Extension has set up some webRequest listeners to redirect requests.
+ // Open the test page and verify that the extension has redirected the
+ // scripts as expected.
+ mainSession.loadTestPath(TRACKERS_PATH)
+ sessionRule.waitForPageStop()
+
+ val textContent = mainSession.evaluateJS("document.body.textContent.replace(/\\s/g, '')")
+ assertThat("The extension should have rewritten the script requests and the body",
+ textContent as String, equalTo("start,extension-was-here,end"))
+
+ sessionRule.waitForResult(controller.uninstall(extension))
+ }
+
+ @Test
+ fun loadWebExtensionPage() {
+ val result = GeckoResult<String>()
+ var extension: WebExtension? = null
+
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any,
+ sender: WebExtension.MessageSender): GeckoResult<Any>? {
+ assertEquals(extension!!.id, sender.webExtension.id)
+ assertEquals(WebExtension.MessageSender.ENV_TYPE_EXTENSION,
+ sender.environmentType)
+ result.complete(message as String)
+
+ return null
+ }
+ }
+
+ extension = sessionRule.waitForResult(controller.ensureBuiltIn(
+ "resource://android/assets/web_extensions/extension-page-update/",
+ "extension-page-update@tests.mozilla.org"))
+
+ val sessionController = mainSession.webExtensionController
+ sessionController.setMessageDelegate(extension, messageDelegate, "browser")
+ sessionController.setTabDelegate(extension, object: WebExtension.SessionTabDelegate {
+ override fun onUpdateTab(extension: WebExtension,
+ session: GeckoSession,
+ details: WebExtension.UpdateTabDetails): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ mainSession.loadUri("http://example.com")
+
+ mainSession.waitUntilCalled(object : Callbacks.NavigationDelegate, Callbacks.ProgressDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ assertThat("Url should load example.com first",
+ url, equalTo("http://example.com/"))
+ }
+
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page should load successfully.",
+ success, equalTo(true))
+ }
+ })
+
+
+ var page: String? = null
+ val pageStop = GeckoResult<Boolean>()
+
+ mainSession.delegateUntilTestEnd(object : Callbacks.NavigationDelegate, Callbacks.ProgressDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?) {
+ page = url
+ }
+
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ if (success && page != null && page!!.endsWith("/tab.html")) {
+ pageStop.complete(true)
+ }
+ }
+ })
+
+ // If ensureBuiltIn works correctly, this will not re-install the extension.
+ // We can verify that it won't reinstall because that would cause the extension page to
+ // close prematurely, making the test fail.
+ val ensure = sessionRule.waitForResult(controller.ensureBuiltIn(
+ "resource://android/assets/web_extensions/extension-page-update/",
+ "extension-page-update@tests.mozilla.org"))
+
+ assertThat("ID match", ensure.id, equalTo(extension.id))
+ assertThat("version match", ensure.metaData.version, equalTo(extension.metaData.version))
+
+ // Make sure the page loaded successfully
+ sessionRule.waitForResult(pageStop)
+
+ assertThat("Url should load WebExtension page", page, endsWith("/tab.html"))
+
+ assertThat("WebExtension page should have access to privileged APIs",
+ sessionRule.waitForResult(result), equalTo("HELLO_FROM_PAGE"))
+
+ // Test that after uninstalling an extension, all its pages get closed
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtension.SessionTabDelegate::class,
+ { delegate -> mainSession.webExtensionController.setTabDelegate(extension, delegate) },
+ { mainSession.webExtensionController.setTabDelegate(extension, null) },
+ object : WebExtension.SessionTabDelegate {})
+
+ val uninstall = controller.uninstall(extension)
+
+ sessionRule.waitUntilCalled(object : WebExtension.SessionTabDelegate {
+ @AssertCalled
+ override fun onCloseTab(source: WebExtension?,
+ session: GeckoSession): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.id, source!!.id)
+ assertEquals(mainSession, session)
+ return GeckoResult.ALLOW
+ }
+ })
+
+ sessionRule.waitForResult(uninstall)
+ }
+
+ @Test
+ fun badUrl() {
+ testInstallBuiltInError("invalid url", "Could not parse uri")
+ }
+
+ @Test
+ fun badHost() {
+ testInstallBuiltInError("resource://gre/", "Only resource://android")
+ }
+
+ @Test
+ fun dontAllowRemoteUris() {
+ testInstallBuiltInError("https://example.com/extension/", "Only resource://android")
+ }
+
+ @Test
+ fun badFileType() {
+ testInstallBuiltInError("resource://android/bad/location/error",
+ "does not point to a folder")
+ }
+
+ @Test
+ fun badLocationXpi() {
+ testInstallBuiltInError("resource://android/bad/location/error.xpi",
+ "does not point to a folder")
+ }
+
+ @Test
+ fun testInstallBuiltInError() {
+ testInstallBuiltInError("resource://android/bad/location/error/",
+ "does not contain a valid manifest")
+ }
+
+ private fun testInstallBuiltInError(location: String, expectedError: String) {
+ try {
+ sessionRule.waitForResult(controller.installBuiltIn(location))
+ } catch (ex: Exception) {
+ // Let's make sure the error message contains the expected error message
+ assertTrue(ex.message!!.contains(expectedError))
+
+ return
+ }
+
+ fail("The above code should throw.")
+ }
+
+ // Test the basic update extension flow with no new permissions.
+ @Test
+ fun update() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.metaData.version, "1.0")
+
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val update1 = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/update-1.xpi"))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was applied by checking the border color
+ assertBodyBorderEqualTo("red")
+
+ val update2 = sessionRule.waitForResult(controller.update(update1));
+ assertEquals(update2.metaData.version, "2.0")
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that updated extension changed the border color.
+ assertBodyBorderEqualTo("blue")
+
+ // Uninstall WebExtension and check again
+ sessionRule.waitForResult(controller.uninstall(update2))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was not applied after being uninstalled
+ assertBodyBorderEqualTo("")
+ }
+
+ // Test extension updating when the new extension has different permissions.
+ @Test
+ fun updateWithPerms() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.metaData.version, "1.0")
+
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val update1 = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/update-with-perms-1.xpi"))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was applied by checking the border color
+ assertBodyBorderEqualTo("red")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onUpdatePrompt(currentlyInstalled: WebExtension,
+ updatedExtension: WebExtension,
+ newPermissions: Array<String>,
+ newOrigins: Array<String>): GeckoResult<AllowOrDeny> {
+ assertEquals(currentlyInstalled.metaData.version, "1.0")
+ assertEquals(updatedExtension.metaData.version, "2.0")
+ assertEquals(newPermissions.size, 1)
+ assertEquals(newPermissions[0], "tabs")
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+ })
+
+ val update2 = sessionRule.waitForResult(controller.update(update1));
+ assertEquals(update2.metaData.version, "2.0")
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that updated extension changed the border color.
+ assertBodyBorderEqualTo("blue")
+
+ // Uninstall WebExtension and check again
+ sessionRule.waitForResult(controller.uninstall(update2))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was not applied after being uninstalled
+ assertBodyBorderEqualTo("")
+ }
+
+ // Ensure update extension works as expected when there is no update available.
+ @Test
+ fun updateNotAvailable() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.metaData.version, "2.0")
+
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val update1 = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/update-2.xpi"))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was applied by checking the border color
+ assertBodyBorderEqualTo("blue")
+
+ val update2 = sessionRule.waitForResult(controller.update(update1))
+ assertNull(update2);
+
+ // Uninstall WebExtension and check again
+ sessionRule.waitForResult(controller.uninstall(update1))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was not applied after being uninstalled
+ assertBodyBorderEqualTo("")
+ }
+
+ // Test denying an extension update.
+ @Test
+ fun updateDenyPerms() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.metaData.version, "1.0")
+
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val update1 = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/update-with-perms-1.xpi"))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was applied by checking the border color
+ assertBodyBorderEqualTo("red")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onUpdatePrompt(currentlyInstalled: WebExtension,
+ updatedExtension: WebExtension,
+ newPermissions: Array<String>,
+ newOrigins: Array<String>): GeckoResult<AllowOrDeny> {
+ assertEquals(currentlyInstalled.metaData.version, "1.0")
+ assertEquals(updatedExtension.metaData.version, "2.0")
+ return GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+ })
+
+
+ sessionRule.waitForResult(controller.update(update1).accept({
+ // We should not be able to update the extension.
+ assertTrue(false)
+ }, { exception ->
+ assertTrue(exception is WebExtension.InstallException)
+ val installException = exception as WebExtension.InstallException
+ assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED)
+ }));
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that updated extension changed the border color.
+ assertBodyBorderEqualTo("red")
+
+ // Uninstall WebExtension and check again
+ sessionRule.waitForResult(controller.uninstall(update1))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was not applied after being uninstalled
+ assertBodyBorderEqualTo("")
+ }
+
+ @Test(expected = CancellationException::class)
+ fun cancelInstall() {
+ val install = controller.install("$TEST_ENDPOINT/stall/test.xpi")
+ val cancel = sessionRule.waitForResult(install.cancel())
+ assertTrue(cancel)
+
+ sessionRule.waitForResult(install)
+ }
+
+ @Test
+ fun cancelInstallFailsAfterInstalled() {
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ var install = controller.install("resource://android/assets/web_extensions/borderify.xpi");
+ val borderify = sessionRule.waitForResult(install)
+
+ val cancel = sessionRule.waitForResult(install.cancel())
+ assertFalse(cancel)
+
+ sessionRule.waitForResult(controller.uninstall(borderify))
+ }
+
+ @Test
+ fun updatePostpone() {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false,
+ "extensions.webextensions.warnings-as-errors" to false
+ ))
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ // First let's check that the color of the border is empty before loading
+ // the WebExtension
+ assertBodyBorderEqualTo("")
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ assertEquals(extension.metaData.version, "1.0")
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val update1 = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/update-postpone-1.xpi"))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension was applied by checking the border color
+ assertBodyBorderEqualTo("red")
+
+ sessionRule.waitForResult(controller.update(update1).accept({
+ // We should not be able to update the extension.
+ assertTrue(false)
+ }, { exception ->
+ assertTrue(exception is WebExtension.InstallException)
+ val installException = exception as WebExtension.InstallException
+ assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED)
+ }));
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ // Check that the WebExtension is still the first extension.
+ assertBodyBorderEqualTo("red")
+
+ sessionRule.waitForResult(controller.uninstall(update1))
+ }
+
+ /*
+ This function installs a web extension, disables it, updates it and uninstalls it
+
+ @param source: Int - represents a logical type; can be EnableSource.APP or EnableSource.USER
+ */
+ private fun testUpdatingExtensionDisabledBy(source: Int) {
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val webExtension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/update-1.xpi"))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ val disabledWebExtension = sessionRule.waitForResult(controller.disable(webExtension, source))
+
+ when (source) {
+ EnableSource.APP -> checkDisabledState(disabledWebExtension, appDisabled=true)
+ EnableSource.USER -> checkDisabledState(disabledWebExtension, userDisabled=true)
+ }
+
+ val updatedWebExtension = sessionRule.waitForResult(controller.update(disabledWebExtension))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ sessionRule.waitForResult(controller.uninstall(updatedWebExtension))
+ }
+
+ @Test
+ fun updateDisabledByUser() {
+ testUpdatingExtensionDisabledBy(EnableSource.USER)
+ }
+
+ @Test
+ fun updateDisabledByApp() {
+ testUpdatingExtensionDisabledBy(EnableSource.APP)
+ }
+
+ // This test
+ // - Listen for a newTab request from a web extension
+ // - Registers a web extension
+ // - Waits for onNewTab request
+ // - Verify that request came from right extension
+ @Test
+ fun testBrowserRuntimeOpenOptionsPageInNewTab() {
+ val tabsCreateResult = GeckoResult<Void>()
+ var optionsExtension: WebExtension? = null
+ val tabDelegate = object : WebExtension.TabDelegate {
+ @AssertCalled(count = 1)
+ override fun onNewTab(
+ source: WebExtension,
+ details: WebExtension.CreateTabDetails)
+ : GeckoResult<GeckoSession> {
+ assertThat(details.url, endsWith("options.html"))
+ assertEquals(details.active, true)
+ assertEquals(optionsExtension!!.id, source.id)
+ tabsCreateResult.complete(null)
+ return GeckoResult.fromValue(null)
+ }
+ }
+
+ optionsExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(OPENOPTIONSPAGE_1_BACKGROUND))
+ optionsExtension.setTabDelegate(tabDelegate)
+ sessionRule.waitForResult(tabsCreateResult)
+
+ sessionRule.waitForResult(controller.uninstall(optionsExtension))
+ }
+
+ // This test
+ // - Listen for an openOptionsPage request from a web extension
+ // - Registers a web extension
+ // - Waits for onOpenOptionsPage request
+ // - Verify that request came from right extension
+ @Test
+ fun testBrowserRuntimeOpenOptionsPageDelegate() {
+ val openOptionsPageResult = GeckoResult<Void>()
+ var optionsExtension: WebExtension? = null
+ val tabDelegate = object : WebExtension.TabDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenOptionsPage(source: WebExtension) {
+ assertThat(
+ source.metaData.optionsPageUrl,
+ endsWith("options.html"))
+ assertEquals(optionsExtension!!.id, source.id)
+ openOptionsPageResult.complete(null)
+ }
+ }
+
+ optionsExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(OPENOPTIONSPAGE_2_BACKGROUND))
+ optionsExtension.setTabDelegate(tabDelegate)
+ sessionRule.waitForResult(openOptionsPageResult)
+
+ sessionRule.waitForResult(controller.uninstall(optionsExtension))
+ }
+
+ // This test checks if the request from Web Extension is processed correctly in Java
+ // the Boolean flags are true, other options have non-default values
+ @Test
+ fun testDownloadsFlagsTrue() {
+ val uri = createTestUrl("/assets/www/images/test.gif")
+
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val webExtension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/download-flags-true.xpi"))
+
+ val assertOnDownloadCalled = GeckoResult<WebExtension.Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.Download>? {
+ assertEquals(webExtension!!.id, source.id)
+ assertEquals(uri, request.request.uri)
+ assertEquals("POST", request.request.method)
+
+ request.request.body?.rewind()
+ val result = Charset.forName("UTF-8").decode(request.request.body!!).toString()
+ assertEquals("postbody", result)
+
+ assertEquals("Mozilla Firefox", request.request.headers.get("User-Agent"))
+ assertEquals("banana.gif", request.filename)
+ assertTrue(request.allowHttpErrors)
+ assertTrue(request.saveAs)
+ assertEquals(GeckoWebExecutor.FETCH_FLAGS_PRIVATE, request.downloadFlags)
+ assertEquals(DownloadRequest.CONFLICT_ACTION_OVERWRITE, request.conflictActionFlag)
+
+ val download = controller.createDownload(1)
+ assertOnDownloadCalled.complete(download)
+ return GeckoResult.fromValue(download)
+ }
+ }
+
+ webExtension.setDownloadDelegate(downloadDelegate)
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ try {
+ sessionRule.waitForResult(assertOnDownloadCalled)
+ } catch (exception: UiThreadUtils.TimeoutException) {
+ controller.setAllowedInPrivateBrowsing(webExtension, true)
+ val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled)
+ assertNotNull(downloadCreated.id)
+
+ sessionRule.waitForResult(controller.uninstall(webExtension))
+ }
+ }
+
+ // This test checks if the request from Web Extension is processed correctly in Java
+ // the Boolean flags are absent/false, other options have default values
+ @Test
+ fun testDownloadsFlagsFalse() {
+ val uri = createTestUrl("/assets/www/images/test.gif")
+
+ sessionRule.setPrefsUntilTestEnd(mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false
+ ))
+
+ mainSession.loadUri("example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ })
+
+ val webExtension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/download-flags-false.xpi"))
+
+ val assertOnDownloadCalled = GeckoResult<WebExtension.Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.Download>? {
+ assertEquals(webExtension!!.id, source.id)
+ assertEquals(uri, request.request.uri)
+ assertEquals("GET", request.request.method)
+ assertNull(request.request.body)
+ assertEquals(0, request.request.headers.size)
+ assertNull(request.filename)
+ assertFalse(request.allowHttpErrors)
+ assertFalse(request.saveAs)
+ assertEquals(GeckoWebExecutor.FETCH_FLAGS_NONE, request.downloadFlags)
+ assertEquals(DownloadRequest.CONFLICT_ACTION_UNIQUIFY, request.conflictActionFlag)
+
+ val download = controller.createDownload(2)
+ assertOnDownloadCalled.complete(download)
+ return GeckoResult.fromValue(download)
+ }
+ }
+
+ webExtension.setDownloadDelegate(downloadDelegate)
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled)
+ assertNotNull(downloadCreated.id)
+ sessionRule.waitForResult(controller.uninstall(webExtension))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt
new file mode 100644
index 0000000000..193c6cee9c
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt
@@ -0,0 +1,156 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.WebNotification
+import org.mozilla.geckoview.WebNotificationDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.util.Callbacks
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class WebNotificationTest : BaseSessionTest() {
+
+ @Before fun setup() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+
+ // Grant "desktop notification" permission
+ mainSession.delegateUntilTestEnd(object : Callbacks.PermissionDelegate {
+ override fun onContentPermissionRequest(session: GeckoSession, uri: String?, type: Int, callback: GeckoSession.PermissionDelegate.Callback) {
+ assertThat("Should grant DESKTOP_NOTIFICATIONS permission", type, equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+ callback.grant()
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+ assertThat("Permission should be granted",
+ result as String, equalTo("granted"))
+ }
+
+ @Test fun onShowNotification() {
+ val runtime = sessionRule.runtime
+ val notificationResult = GeckoResult<Void>()
+ val register = { delegate: WebNotificationDelegate -> runtime.webNotificationDelegate = delegate}
+ val unregister = { _: WebNotificationDelegate -> runtime.webNotificationDelegate = null }
+ val requireInteraction =
+ sessionRule.getPrefs("dom.webnotifications.requireinteraction.enabled")[0] as Boolean
+
+ sessionRule.addExternalDelegateDuringNextWait(WebNotificationDelegate::class, register,
+ unregister, object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ assertThat("Title should match", notification.title, equalTo("The Title"))
+ assertThat("Body should match", notification.text, equalTo("The Text"))
+ assertThat("Tag should match", notification.tag, endsWith("Tag"))
+ assertThat("ImageUrl should match", notification.imageUrl, endsWith("icon.png"))
+ assertThat("Language should match", notification.lang, equalTo("en-US"))
+ assertThat("Direction should match", notification.textDirection, equalTo("ltr"))
+ assertThat("Require Interaction should match", notification.requireInteraction,
+ equalTo(requireInteraction))
+ assertThat("Source should match", notification.source, equalTo(createTestUrl(HELLO_HTML_PATH)))
+ notificationResult.complete(null)
+ }
+ })
+
+ mainSession.evaluateJS("""
+ new Notification('The Title', { body: 'The Text', cookie: 'Cookie',
+ icon: 'icon.png', tag: 'Tag', dir: 'ltr', lang: 'en-US',
+ requireInteraction: true });
+ """.trimIndent())
+
+ sessionRule.waitForResult(notificationResult)
+ }
+
+ @Test fun onCloseNotification() {
+ val runtime = sessionRule.runtime
+ val closeCalled = GeckoResult<Void>()
+ val register = { delegate: WebNotificationDelegate -> runtime.webNotificationDelegate = delegate}
+ val unregister = { _: WebNotificationDelegate -> runtime.webNotificationDelegate = null }
+
+ sessionRule.addExternalDelegateDuringNextWait(WebNotificationDelegate::class, register,
+ unregister, object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onCloseNotification(notification: WebNotification) {
+ closeCalled.complete(null)
+ }
+ })
+
+ mainSession.evaluateJS("""
+ const notification = new Notification('The Title', { body: 'The Text'});
+ notification.close();
+ """.trimIndent())
+
+ sessionRule.waitForResult(closeCalled)
+ }
+
+ @Test fun clickNotification() {
+ val runtime = sessionRule.runtime
+ val notificationResult = GeckoResult<Void>()
+ val register = { delegate: WebNotificationDelegate -> runtime.webNotificationDelegate = delegate}
+ val unregister = { _: WebNotificationDelegate -> runtime.webNotificationDelegate = null }
+ var notificationShown: WebNotification? = null
+
+ sessionRule.addExternalDelegateDuringNextWait(WebNotificationDelegate::class, register,
+ unregister, object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationShown = notification
+ notificationResult.complete(null)
+ }
+ })
+
+ val promiseResult = mainSession.evaluatePromiseJS("""
+ new Promise(resolve => {
+ const notification = new Notification('The Title', { body: 'The Text' });
+ notification.onclick = function() {
+ resolve(1);
+ }
+ });
+ """.trimIndent())
+
+ sessionRule.waitForResult(notificationResult)
+ notificationShown!!.click()
+
+ assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0))
+ }
+
+ @Test fun dismissNotification() {
+ val runtime = sessionRule.runtime
+ val notificationResult = GeckoResult<Void>()
+ val register = { delegate: WebNotificationDelegate -> runtime.webNotificationDelegate = delegate}
+ val unregister = { _: WebNotificationDelegate -> runtime.webNotificationDelegate = null }
+ var notificationShown: WebNotification? = null
+
+ sessionRule.addExternalDelegateDuringNextWait(WebNotificationDelegate::class, register,
+ unregister, object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationShown = notification
+ notificationResult.complete(null)
+ }
+ })
+
+ val promiseResult = mainSession.evaluatePromiseJS("""
+ new Promise(resolve => {
+ const notification = new Notification('The Title', { body: 'The Text'});
+ notification.onclose = function() {
+ resolve(1);
+ }
+ });
+ """.trimIndent())
+
+ sessionRule.waitForResult(notificationResult)
+ notificationShown!!.dismiss()
+
+ assertThat("Promise should have been resolved", promiseResult.value as Double, equalTo(1.0))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt
new file mode 100644
index 0000000000..2f438d15fe
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt
@@ -0,0 +1,245 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.os.Parcel
+import androidx.test.filters.MediumTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import android.util.Base64
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException
+import org.mozilla.geckoview.test.util.Callbacks
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.SecureRandom
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECGenParameterSpec
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class WebPushTest : BaseSessionTest() {
+ companion object {
+ val PUSH_ENDPOINT: String = "https://test.endpoint"
+ val APP_SERVER_KEY_PAIR: KeyPair = generateKeyPair()
+ val AUTH_SECRET: ByteArray = generateAuthSecret()
+ val BROWSER_KEY_PAIR: KeyPair = generateKeyPair()
+
+ private fun generateKeyPair(): KeyPair {
+ try {
+ val spec = ECGenParameterSpec("secp256r1")
+ val generator = KeyPairGenerator.getInstance("EC")
+ generator.initialize(spec)
+ return generator.generateKeyPair()
+ } catch (e: Exception) {
+ throw RuntimeException(e)
+ }
+ }
+
+ private fun generateAuthSecret(): ByteArray {
+ val bytes = ByteArray(16)
+ SecureRandom().nextBytes(bytes)
+
+ return bytes
+ }
+ }
+
+ var delegate: TestPushDelegate? = null
+
+ @Before
+ fun setup() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ // Grant "desktop notification" permission
+ mainSession.delegateUntilTestEnd(object : Callbacks.PermissionDelegate {
+ override fun onContentPermissionRequest(session: GeckoSession, uri: String?, type: Int, callback: GeckoSession.PermissionDelegate.Callback) {
+ assertThat("Should grant DESKTOP_NOTIFICATIONS permission", type, equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+ callback.grant()
+ }
+ })
+
+ delegate = TestPushDelegate()
+
+ sessionRule.addExternalDelegateUntilTestEnd(WebPushDelegate::class,
+ { d -> sessionRule.runtime.webPushController.setDelegate(d) },
+ { sessionRule.runtime.webPushController.setDelegate(null) }, delegate!!)
+
+
+ mainSession.loadTestPath(PUSH_HTML_PATH)
+ mainSession.waitForPageStop()
+ }
+
+ @After
+ fun tearDown() {
+ sessionRule.runtime.webPushController.setDelegate(null)
+ delegate = null
+ }
+
+ private fun verifySubscription(subscription: JSONObject) {
+ assertThat("Push endpoint should match", subscription.getString("endpoint"), equalTo(PUSH_ENDPOINT))
+
+ val keys = subscription.getJSONObject("keys")
+ val authSecret = Base64.decode(keys.getString("auth"), Base64.URL_SAFE)
+ val encryptionKey = WebPushUtils.keyFromString(keys.getString("p256dh"))
+
+ assertThat("Auth secret should match", authSecret, equalTo(AUTH_SECRET))
+ assertThat("Encryption key should match", encryptionKey, equalTo(BROWSER_KEY_PAIR.public))
+ }
+
+ @Test
+ fun subscribe() {
+ // PushManager.subscribe()
+ val appServerKey = WebPushUtils.keyToString(APP_SERVER_KEY_PAIR.public as ECPublicKey)
+ var pushSubscription = mainSession.evaluatePromiseJS("window.doSubscribe(\"$appServerKey\")").value as JSONObject
+ assertThat("Should have a stored subscription", delegate!!.storedSubscription, notNullValue())
+ verifySubscription(pushSubscription)
+
+ // PushManager.getSubscription()
+ pushSubscription = mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject
+ verifySubscription(pushSubscription)
+ }
+
+ @Test
+ fun subscribeNoAppServerKey() {
+ // PushManager.subscribe()
+ var pushSubscription = mainSession.evaluatePromiseJS("window.doSubscribe()").value as JSONObject
+ assertThat("Should have a stored subscription", delegate!!.storedSubscription, notNullValue())
+ verifySubscription(pushSubscription)
+
+ // PushManager.getSubscription()
+ pushSubscription = mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject
+ verifySubscription(pushSubscription)
+ }
+
+ @Test(expected = RejectedPromiseException::class)
+ fun subscribeNullDelegate() {
+ sessionRule.runtime.webPushController.setDelegate(null)
+ mainSession.evaluatePromiseJS("window.doSubscribe()").value as JSONObject
+ }
+
+ @Test(expected = RejectedPromiseException::class)
+ fun getSubscriptionNullDelegate() {
+ sessionRule.runtime.webPushController.setDelegate(null)
+ mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject
+ }
+
+ @Test
+ fun unsubscribe() {
+ subscribe()
+
+ // PushManager.unsubscribe()
+ val unsubResult = mainSession.evaluatePromiseJS("window.doUnsubscribe()").value as JSONObject
+ assertThat("Unsubscribe result should be non-null", unsubResult, notNullValue())
+ assertThat("Should not have a stored subscription", delegate!!.storedSubscription, nullValue())
+ }
+
+ @Test
+ fun pushEvent() {
+ subscribe()
+
+ val p = mainSession.evaluatePromiseJS("window.doWaitForPushEvent()")
+
+ val testPayload = "The Payload";
+ sessionRule.runtime.webPushController.onPushEvent(delegate!!.storedSubscription!!.scope, testPayload.toByteArray(Charsets.UTF_8))
+
+ assertThat("Push data should match", p.value as String, equalTo(testPayload))
+ }
+
+ private fun sendNotification() {
+ val notificationResult = GeckoResult<Void>()
+ val runtime = sessionRule.runtime
+ val register = { delegate: WebNotificationDelegate -> runtime.webNotificationDelegate = delegate}
+ val unregister = { _: WebNotificationDelegate -> runtime.webNotificationDelegate = null }
+
+ val expectedTitle = "The title"
+ val expectedBody = "The body"
+
+ sessionRule.addExternalDelegateDuringNextWait(WebNotificationDelegate::class, register,
+ unregister, object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ assertThat("Title should match", notification.title, equalTo(expectedTitle))
+ assertThat("Body should match", notification.text, equalTo(expectedBody))
+ assertThat("Source should match", notification.source, endsWith("sw.js"))
+ notificationResult.complete(null)
+ }
+ })
+
+ val testPayload = JSONObject()
+ testPayload.put("title", expectedTitle)
+ testPayload.put("body", expectedBody)
+
+ sessionRule.runtime.webPushController.onPushEvent(delegate!!.storedSubscription!!.scope, testPayload.toString().toByteArray(Charsets.UTF_8))
+ sessionRule.waitForResult(notificationResult)
+ }
+
+ @Test
+ fun pushEventWithNotification() {
+ subscribe()
+ sendNotification()
+ }
+
+ @Test
+ fun subscriptionChanged() {
+ subscribe()
+
+ val p = mainSession.evaluatePromiseJS("window.doWaitForSubscriptionChange()")
+
+ sessionRule.runtime.webPushController.onSubscriptionChanged(delegate!!.storedSubscription!!.scope)
+
+ assertThat("Result should not be null", p.value, notNullValue())
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun invalidDuplicateKeys() {
+ WebPushSubscription("https://scope", PUSH_ENDPOINT,
+ WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey),
+ WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey)!!, AUTH_SECRET)
+ }
+
+ @Test
+ fun parceling() {
+ val testScope = "https://test.scope";
+ val sub = WebPushSubscription(testScope, PUSH_ENDPOINT,
+ WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey),
+ WebPushUtils.keyToBytes(BROWSER_KEY_PAIR.public as ECPublicKey)!!, AUTH_SECRET)
+
+ val parcel = Parcel.obtain()
+ sub.writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+
+ val sub2 = WebPushSubscription.CREATOR.createFromParcel(parcel)
+ assertThat("Scope should match", sub.scope, equalTo(sub2.scope))
+ assertThat("Endpoint should match", sub.endpoint, equalTo(sub2.endpoint))
+ assertThat("App server key should match", sub.appServerKey, equalTo(sub2.appServerKey))
+ assertThat("Encryption key should match", sub.browserPublicKey, equalTo(sub2.browserPublicKey))
+ assertThat("Auth secret should match", sub.authSecret, equalTo(sub2.authSecret))
+ }
+
+ class TestPushDelegate : WebPushDelegate {
+ var storedSubscription: WebPushSubscription? = null
+
+ override fun onGetSubscription(scope: String): GeckoResult<WebPushSubscription>? {
+ return GeckoResult.fromValue(storedSubscription)
+ }
+
+ override fun onUnsubscribe(scope: String): GeckoResult<Void>? {
+ storedSubscription = null
+ return GeckoResult.fromValue(null)
+ }
+
+ override fun onSubscribe(scope: String, appServerKey: ByteArray?): GeckoResult<WebPushSubscription>? {
+ appServerKey?.let { assertThat("Application server key should match", it, equalTo(WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey))) }
+ storedSubscription = WebPushSubscription(scope, PUSH_ENDPOINT, appServerKey, WebPushUtils.keyToBytes(BROWSER_KEY_PAIR.public as ECPublicKey)!!, AUTH_SECRET)
+ return GeckoResult.fromValue(storedSubscription)
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java
new file mode 100644
index 0000000000..a4b2120458
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java
@@ -0,0 +1,168 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.Nullable;
+import android.util.Base64;
+
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyFactory;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.ECPublicKeySpec;
+import java.security.spec.InvalidKeySpecException;
+
+/**
+ * Utilities for converting {@link ECPublicKey} to/from X9.62 encoding.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a>
+ */
+/* package */ class WebPushUtils {
+ public static final int P256_PUBLIC_KEY_LENGTH = 65; // 1 + 32 + 32
+ private static final byte NIST_HEADER = 0x04; // uncompressed format
+
+ private static ECParameterSpec sSpec;
+
+ private WebPushUtils() {
+ }
+
+ /**
+ * Encodes an {@link ECPublicKey} into X9.62 format as required
+ * by Web Push.
+ *
+ * @param key the {@link ECPublicKey} to encode
+ * @return the encoded {@link ECPublicKey}
+ */
+ @AnyThread
+ public static @Nullable byte[] keyToBytes(final @Nullable ECPublicKey key) {
+ if (key == null) {
+ return null;
+ }
+
+ final ByteBuffer buffer = ByteBuffer.allocate(P256_PUBLIC_KEY_LENGTH);
+ buffer.put(NIST_HEADER);
+
+ putUnsignedBigInteger(buffer, key.getW().getAffineX());
+ putUnsignedBigInteger(buffer, key.getW().getAffineY());
+
+ if (buffer.position() != P256_PUBLIC_KEY_LENGTH) {
+ throw new RuntimeException("Unexpected key length " + buffer.position());
+ }
+
+ return buffer.array();
+ }
+
+ private static void putUnsignedBigInteger(final ByteBuffer buffer, final BigInteger value) {
+ final byte[] bytes = value.toByteArray();
+ if (bytes.length < 32) {
+ buffer.put(new byte[32 - bytes.length]);
+ buffer.put(bytes);
+ } else {
+ buffer.put(bytes, bytes.length - 32, 32);
+ }
+ }
+
+ /**
+ * Encodes an {@link ECPublicKey} into X9.62 format as required
+ * by Web Push, further encoded into Base64.
+ *
+ * @param key the {@link ECPublicKey} to encode
+ * @return the encoded {@link ECPublicKey}
+ */
+ @AnyThread
+ public static @Nullable String keyToString(final @Nullable ECPublicKey key) {
+ return Base64.encodeToString(keyToBytes(key), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
+ }
+
+ /**
+ * @return A {@link ECParameterSpec} for P-256 (secp256r1).
+ */
+ public static ECParameterSpec getP256Spec() {
+ if (sSpec == null) {
+ try {
+ final KeyPairGenerator gen = KeyPairGenerator.getInstance("EC");
+ final ECGenParameterSpec genSpec = new ECGenParameterSpec("secp256r1");
+ gen.initialize(genSpec);
+ sSpec = ((ECPublicKey) gen.generateKeyPair().getPublic()).getParams();
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (InvalidAlgorithmParameterException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ return sSpec;
+ }
+
+ /**
+ * Converts a Base64 X9.62 encoded Web Push key into a {@link ECPublicKey}.
+ *
+ * @param base64Bytes the X9.62 data as Base64
+ * @return a {@link ECPublicKey}
+ */
+ @AnyThread
+ public static @Nullable ECPublicKey keyFromString(final @Nullable String base64Bytes) {
+ if (base64Bytes == null) {
+ return null;
+ }
+
+ return keyFromBytes(Base64.decode(base64Bytes, Base64.URL_SAFE));
+ }
+
+ private static BigInteger readUnsignedBigInteger(final byte[] bytes, final int offset, final int length) {
+ byte[] mag = bytes;
+ if (offset != 0 || length != bytes.length) {
+ mag = new byte[length];
+ System.arraycopy(bytes, offset, mag, 0, length);
+ }
+ return new BigInteger(1, mag);
+ }
+
+ /**
+ * Converts a X9.62 encoded Web Push key into a {@link ECPublicKey}.
+ *
+ * @param bytes the X9.62 data
+ * @return a {@link ECPublicKey}
+ */
+ @AnyThread
+ public static @Nullable ECPublicKey keyFromBytes(final @Nullable byte[] bytes) {
+ if (bytes == null) {
+ return null;
+ }
+
+ if (bytes.length != P256_PUBLIC_KEY_LENGTH) {
+ throw new IllegalArgumentException(String.format("Expected exactly %d bytes", P256_PUBLIC_KEY_LENGTH));
+ }
+
+ if (bytes[0] != NIST_HEADER) {
+ throw new IllegalArgumentException("Expected uncompressed NIST format");
+ }
+
+ try {
+ final BigInteger x = readUnsignedBigInteger(bytes, 1, 32);
+ final BigInteger y = readUnsignedBigInteger(bytes, 33, 32);
+
+ final ECPoint point = new ECPoint(x, y);
+ final ECPublicKeySpec spec = new ECPublicKeySpec(point, getP256Spec());
+ final KeyFactory factory = KeyFactory.getInstance("EC");
+ final ECPublicKey key = (ECPublicKey) factory.generatePublic(spec);
+
+ return key;
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (InvalidKeySpecException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt
new file mode 100644
index 0000000000..04bd4a27fc
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt
@@ -0,0 +1,62 @@
+package org.mozilla.geckoview.test.crash
+
+import android.content.Intent
+import android.os.Message
+import android.os.Messenger
+import androidx.test.annotation.UiThreadTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.MediumTest
+import androidx.test.rule.ServiceTestRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.notNullValue
+import org.junit.Assert.assertThat
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.test.TestCrashHandler
+import org.mozilla.geckoview.test.util.Environment
+import org.mozilla.geckoview.test.util.RuntimeCreator
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ParentCrashTest {
+ lateinit var messenger: Messenger
+ val env = Environment()
+
+ @get:Rule val rule = ServiceTestRule()
+
+ @Before
+ fun setup() {
+ // Since this test starts up its own GeckoRuntime via
+ // RemoteGeckoService, we need to shutdown any runtime already running
+ // in the RuntimeCreator.
+ RuntimeCreator.shutdownRuntime()
+
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val binder = rule.bindService(Intent(context, RemoteGeckoService::class.java))
+ messenger = Messenger(binder)
+ assertThat("messenger should not be null", binder, notNullValue())
+ }
+
+ @Test
+ @UiThreadTest
+ fun crashParent() {
+ // TODO: Bug 1673956
+ assumeThat(env.isFission, equalTo(false))
+ val client = TestCrashHandler.Client(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ assertTrue(client.connect(env.defaultTimeoutMillis))
+ client.setEvalNextCrashDump(/* expectFatal */ true)
+
+ messenger.send(Message.obtain(null, RemoteGeckoService.CMD_CRASH_PARENT_NATIVE))
+
+ var evalResult = client.getEvalResult(env.defaultTimeoutMillis)
+ assertTrue(evalResult.mMsg, evalResult.mResult)
+
+ client.disconnect()
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RemoteGeckoService.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RemoteGeckoService.kt
new file mode 100644
index 0000000000..eb2b53b937
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RemoteGeckoService.kt
@@ -0,0 +1,66 @@
+package org.mozilla.geckoview.test.crash
+
+import android.app.Service
+import android.content.Intent
+import android.os.*
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+import org.mozilla.gecko.GeckoProfile
+import org.mozilla.geckoview.GeckoRuntime
+import org.mozilla.geckoview.GeckoRuntimeSettings
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSessionSettings
+import org.mozilla.geckoview.test.TestCrashHandler
+
+class RemoteGeckoService : Service() {
+ companion object {
+ val LOGTAG = "RemoteGeckoService"
+ val CMD_CRASH_PARENT_NATIVE = 1
+ val CMD_CRASH_CONTENT_NATIVE = 2
+ var runtime: GeckoRuntime? = null
+ }
+
+ var session: GeckoSession? = null;
+
+ class TestHandler: Handler() {
+ override fun handleMessage(msg: Message) {
+ when (msg.what) {
+ CMD_CRASH_PARENT_NATIVE -> {
+ val settings = GeckoSessionSettings()
+ val session = GeckoSession(settings)
+ session.open(runtime!!)
+ session.loadUri("about:crashparent")
+ }
+ CMD_CRASH_CONTENT_NATIVE -> {
+ val settings = GeckoSessionSettings.Builder()
+ .build()
+ val session = GeckoSession(settings)
+ session.open(runtime!!)
+ session.loadUri("about:crashcontent")
+ }
+ else -> {
+ throw RuntimeException("Unhandled command")
+ }
+ }
+ }
+ }
+
+ val handler = Messenger(TestHandler())
+
+ override fun onBind(intent: Intent): IBinder {
+ if (runtime == null) {
+ // We need to run in a different profile so we don't conflict with other tests running
+ // in parallel in other processes.
+ val extras = Bundle(1)
+ extras.putString("args", "-P remote")
+
+ runtime = GeckoRuntime.create(this.applicationContext,
+ GeckoRuntimeSettings.Builder()
+ .extras(extras)
+ .crashHandler(TestCrashHandler::class.java).build())
+ }
+
+ return handler.binder
+
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java
new file mode 100644
index 0000000000..8bd10895e1
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java
@@ -0,0 +1,2325 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test.rule;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONTokener;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.Autofill;
+import org.mozilla.geckoview.ContentBlocking;
+import org.mozilla.geckoview.GeckoDisplay;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.GeckoSessionSettings;
+import org.mozilla.geckoview.MediaSession;
+import org.mozilla.geckoview.RuntimeTelemetry;
+import org.mozilla.geckoview.SessionTextInput;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.WebExtensionController;
+import org.mozilla.geckoview.test.util.TestServer;
+import org.mozilla.geckoview.test.util.RuntimeCreator;
+import org.mozilla.geckoview.test.util.Environment;
+import org.mozilla.geckoview.test.util.UiThreadUtils;
+import org.mozilla.geckoview.test.util.Callbacks;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.fail;
+
+import org.hamcrest.Matcher;
+
+import org.json.JSONObject;
+
+import org.junit.rules.ErrorCollector;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import android.app.Instrumentation;
+import android.graphics.Point;
+import android.graphics.SurfaceTexture;
+import android.os.SystemClock;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.test.platform.app.InstrumentationRegistry;
+import android.util.Log;
+import android.util.Pair;
+import android.view.MotionEvent;
+import android.view.Surface;
+
+import java.io.File;
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Pattern;
+
+import kotlin.jvm.JvmClassMappingKt;
+import kotlin.reflect.KClass;
+
+/**
+ * TestRule that, for each test, sets up a GeckoSession, runs the test on the UI thread,
+ * and tears down the GeckoSession at the end of the test. The rule also provides methods
+ * for waiting on particular callbacks to be called, and methods for asserting that
+ * callbacks are called in the proper order.
+ */
+public class GeckoSessionTestRule implements TestRule {
+ private static final String LOGTAG = "GeckoSessionTestRule";
+
+ private static final int TEST_PORT = 4245;
+ public static final String TEST_ENDPOINT = "http://localhost:" + TEST_PORT;
+
+ private static final Method sOnPageStart;
+ private static final Method sOnPageStop;
+ private static final Method sOnNewSession;
+ private static final Method sOnCrash;
+ private static final Method sOnKill;
+
+ static {
+ try {
+ sOnPageStart = GeckoSession.ProgressDelegate.class.getMethod(
+ "onPageStart", GeckoSession.class, String.class);
+ sOnPageStop = GeckoSession.ProgressDelegate.class.getMethod(
+ "onPageStop", GeckoSession.class, boolean.class);
+ sOnNewSession = GeckoSession.NavigationDelegate.class.getMethod(
+ "onNewSession", GeckoSession.class, String.class);
+ sOnCrash = GeckoSession.ContentDelegate.class.getMethod(
+ "onCrash", GeckoSession.class);
+ sOnKill = GeckoSession.ContentDelegate.class.getMethod(
+ "onKill", GeckoSession.class);
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public void addDisplay(final GeckoSession session, final int x, final int y) {
+ final GeckoDisplay display = session.acquireDisplay();
+
+ final SurfaceTexture displayTexture = new SurfaceTexture(0);
+ displayTexture.setDefaultBufferSize(x, y);
+
+ final Surface displaySurface = new Surface(displayTexture);
+ display.surfaceChanged(displaySurface, x, y);
+
+ mDisplays.put(session, display);
+ mDisplayTextures.put(session, displayTexture);
+ mDisplaySurfaces.put(session, displaySurface);
+ }
+
+ public void releaseDisplay(final GeckoSession session) {
+ if (!mDisplays.containsKey(session)) {
+ // No display to release
+ return;
+ }
+ final GeckoDisplay display = mDisplays.remove(session);
+ display.surfaceDestroyed();
+ session.releaseDisplay(display);
+ final Surface displaySurface = mDisplaySurfaces.remove(session);
+ displaySurface.release();
+ final SurfaceTexture displayTexture = mDisplayTextures.remove(session);
+ displayTexture.release();
+ }
+
+ /**
+ * Specify the timeout for any of the wait methods, in milliseconds, relative to
+ * {@link Environment#DEFAULT_TIMEOUT_MILLIS}. When the default timeout scales to account
+ * for differences in the device under test, the timeout value here will be
+ * scaled as well. Can be used on classes or methods.
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface TimeoutMillis {
+ long value();
+ }
+
+ /**
+ * Specify the display size for the GeckoSession in device pixels
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface WithDisplay {
+ int width();
+ int height();
+ }
+
+ /**
+ * Specify that the main session should not be opened at the start of the test.
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface ClosedSessionAtStart {
+ boolean value() default true;
+ }
+
+ /**
+ * Specify that the test will set a delegate to null when creating a session, rather
+ * than setting the delegate to a proxy. The test cannot wait on any delegates that
+ * are set to null.
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface NullDelegate {
+ Class<?> value();
+
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface List {
+ NullDelegate[] value();
+ }
+ }
+
+ /**
+ * Specify a list of GeckoSession settings to be applied to the GeckoSession object
+ * under test. Can be used on classes or methods. Note that the settings values must
+ * be string literals regardless of the type of the settings.
+ * <p>
+ * Enable tracking protection for a particular test:
+ * <pre>
+ * &#64;Setting.List(&#64;Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+ * value = "false"))
+ * &#64;Test public void test() { ... }
+ * </pre>
+ * <p>
+ * Use multiple settings:
+ * <pre>
+ * &#64;Setting.List({&#64;Setting(key = Setting.Key.USE_PRIVATE_MODE,
+ * value = "true"),
+ * &#64;Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+ * value = "false")})
+ * </pre>
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface Setting {
+ enum Key {
+ CHROME_URI,
+ DISPLAY_MODE,
+ ALLOW_JAVASCRIPT,
+ SCREEN_ID,
+ USE_PRIVATE_MODE,
+ USE_TRACKING_PROTECTION,
+ FULL_ACCESSIBILITY_TREE;
+
+ private final GeckoSessionSettings.Key<?> mKey;
+ private final Class<?> mType;
+
+ Key() {
+ final Field field;
+ try {
+ field = GeckoSessionSettings.class.getDeclaredField(name());
+ field.setAccessible(true);
+ mKey = (GeckoSessionSettings.Key<?>) field.get(null);
+ } catch (final NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+
+ final ParameterizedType genericType = (ParameterizedType) field.getGenericType();
+ mType = (Class<?>) genericType.getActualTypeArguments()[0];
+ }
+
+ @SuppressWarnings("unchecked")
+ public void set(final GeckoSessionSettings settings, final String value) {
+ try {
+ if (boolean.class.equals(mType) || Boolean.class.equals(mType)) {
+ Method method = GeckoSessionSettings.class
+ .getDeclaredMethod("setBoolean",
+ GeckoSessionSettings.Key.class,
+ boolean.class);
+ method.setAccessible(true);
+ method.invoke(settings, mKey, Boolean.valueOf(value));
+ } else if (int.class.equals(mType) || Integer.class.equals(mType)) {
+ Method method = GeckoSessionSettings.class
+ .getDeclaredMethod("setInt",
+ GeckoSessionSettings.Key.class,
+ int.class);
+ method.setAccessible(true);
+ try {
+ method.invoke(settings, mKey,
+ (Integer)GeckoSessionSettings.class.getField(value)
+ .get(null));
+ }
+ catch (final NoSuchFieldException | IllegalAccessException |
+ ClassCastException e) {
+ method.invoke(settings, mKey,
+ Integer.valueOf(value));
+ }
+ } else if (String.class.equals(mType)) {
+ Method method = GeckoSessionSettings.class
+ .getDeclaredMethod("setString",
+ GeckoSessionSettings.Key.class,
+ String.class);
+ method.setAccessible(true);
+ method.invoke(settings, mKey, value);
+ } else {
+ throw new IllegalArgumentException("Unsupported type: " +
+ mType.getSimpleName());
+ }
+ } catch (NoSuchMethodException
+ | IllegalAccessException
+ | InvocationTargetException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface List {
+ Setting[] value();
+ }
+
+ Key key();
+ String value();
+ }
+
+ /**
+ * Assert that a method is called or not called, and if called, the order and number
+ * of times it is called. The order number is a monotonically increasing integer; if
+ * an called method's order number is less than the current order number, an exception
+ * is raised for out-of-order call.
+ * <p>
+ * {@code @AssertCalled} asserts the method must be called at least once.
+ * <p>
+ * {@code @AssertCalled(false)} asserts the method must not be called.
+ * <p>
+ * {@code @AssertCalled(order = 2)} asserts the method must be called once and
+ * after any other method with order number less than 2.
+ * <p>
+ * {@code @AssertCalled(order = {2, 4})} asserts order number 2 for first
+ * call and order number 4 for any subsequent calls.
+ * <p>
+ * {@code @AssertCalled(count = 2)} asserts two calls total in any order
+ * with respect to other calls.
+ * <p>
+ * {@code @AssertCalled(count = 2, order = 2)} asserts two calls, both with
+ * order number 2.
+ * <p>
+ * {@code @AssertCalled(count = 2, order = {2, 4, 6})} asserts two calls
+ * total: the first with order number 2 and the second with order number 4.
+ */
+ @Target(ElementType.METHOD)
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface AssertCalled {
+ /**
+ * @return True if the method must be called if count != 0,
+ * or false if the method must not be called.
+ */
+ boolean value() default true;
+
+ /**
+ * @return The number of calls allowed. Specify -1 to allow any number > 0. Specify 0 to
+ * assert the method is not called, even if value() is true.
+ */
+ int count() default -1;
+
+ /**
+ * @return If called, the order number for each call, or 0 to allow arbitrary
+ * order. If order's length is more than count, extra elements are not used;
+ * if order's length is less than count, the last element is repeated.
+ */
+ int[] order() default 0;
+ }
+
+ /**
+ * Interface that represents a function that registers or unregisters a delegate.
+ */
+ public interface DelegateRegistrar<T> {
+ void invoke(T delegate) throws Throwable;
+ }
+
+ /*
+ * If the value here is true, content crashes will be ignored. If false, the test will
+ * be failed immediately if a content crash occurs. This is also the case when
+ * {@link IgnoreCrash} is not present.
+ */
+ @Target(ElementType.METHOD)
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface IgnoreCrash {
+ /**
+ * @return True if content crashes should be ignored, false otherwise. Default is true.
+ */
+ boolean value() default true;
+ }
+
+ public static class ChildCrashedException extends RuntimeException {
+ public ChildCrashedException(final String detailMessage) {
+ super(detailMessage);
+ }
+ }
+
+ public static class RejectedPromiseException extends RuntimeException {
+ private final Object mReason;
+
+ /* package */ RejectedPromiseException(final Object reason) {
+ super(String.valueOf(reason));
+ mReason = reason;
+ }
+
+ public Object getReason() {
+ return mReason;
+ }
+ }
+
+ public static class CallRequirement {
+ public final boolean allowed;
+ public final int count;
+ public final int[] order;
+
+ public CallRequirement(final boolean allowed, final int count, final int[] order) {
+ this.allowed = allowed;
+ this.count = count;
+ this.order = order;
+ }
+ }
+
+ public static class CallInfo {
+ public final int counter;
+ public final int order;
+
+ /* package */ CallInfo(final int counter, final int order) {
+ this.counter = counter;
+ this.order = order;
+ }
+ }
+
+ public static class MethodCall {
+ public final GeckoSession session;
+ public final Method method;
+ public final CallRequirement requirement;
+ public final Object target;
+ private int currentCount;
+
+ public MethodCall(final GeckoSession session, final Method method,
+ final CallRequirement requirement) {
+ this(session, method, requirement, /* target */ null);
+ }
+
+ /* package */ MethodCall(final GeckoSession session, final Method method,
+ final AssertCalled annotation, final Object target) {
+ this(session, method,
+ (annotation != null) ? new CallRequirement(annotation.value(),
+ annotation.count(),
+ annotation.order())
+ : null,
+ /* target */ target);
+ }
+
+ /* package */ MethodCall(final GeckoSession session, final Method method,
+ final CallRequirement requirement, final Object target) {
+ this.session = session;
+ this.method = method;
+ this.requirement = requirement;
+ this.target = target;
+ currentCount = 0;
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (this == other) {
+ return true;
+ } else if (other instanceof MethodCall) {
+ final MethodCall otherCall = (MethodCall) other;
+ return (session == null || otherCall.session == null ||
+ session.equals(otherCall.session)) &&
+ methodsEqual(method, ((MethodCall) other).method);
+ } else if (other instanceof Method) {
+ return methodsEqual(method, (Method) other);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return method.hashCode();
+ }
+
+ /* package */ int getOrder() {
+ if (requirement == null || currentCount == 0) {
+ return 0;
+ }
+
+ final int[] order = requirement.order;
+ if (order == null || order.length == 0) {
+ return 0;
+ }
+ return order[Math.min(currentCount - 1, order.length - 1)];
+ }
+
+ /* package */ int getCount() {
+ return (requirement == null) ? -1 :
+ requirement.allowed ? requirement.count : 0;
+ }
+
+ /* package */ void incrementCounter() {
+ currentCount++;
+ }
+
+ /* package */ int getCurrentCount() {
+ return currentCount;
+ }
+
+ /* package */ boolean allowUnlimitedCalls() {
+ return getCount() == -1;
+ }
+
+ /* package */ boolean allowMoreCalls() {
+ final int count = getCount();
+ return count == -1 || count > currentCount;
+ }
+
+ /* package */ CallInfo getInfo() {
+ return new CallInfo(currentCount, getOrder());
+ }
+
+ // Similar to Method.equals, but treat the same method from an interface and an
+ // overriding class as the same (e.g. CharSequence.length == String.length).
+ private static boolean methodsEqual(final @NonNull Method m1, final @NonNull Method m2) {
+ return (m1.getDeclaringClass().isAssignableFrom(m2.getDeclaringClass()) ||
+ m2.getDeclaringClass().isAssignableFrom(m1.getDeclaringClass())) &&
+ m1.getName().equals(m2.getName()) &&
+ m1.getReturnType().equals(m2.getReturnType()) &&
+ Arrays.equals(m1.getParameterTypes(), m2.getParameterTypes());
+ }
+ }
+
+ protected static class CallRecord {
+ public final Method method;
+ public final MethodCall methodCall;
+ public final Object[] args;
+
+ public CallRecord(final GeckoSession session, final Method method, final Object[] args) {
+ this.method = method;
+ this.methodCall = new MethodCall(session, method, /* requirement */ null);
+ this.args = args;
+ }
+ }
+
+ protected interface CallRecordHandler {
+ boolean handleCall(Method method, Object[] args);
+ }
+
+ protected final class ExternalDelegate<T> {
+ public final Class<T> delegate;
+ private final DelegateRegistrar<T> mRegister;
+ private final DelegateRegistrar<T> mUnregister;
+ private final T mProxy;
+ private boolean mRegistered;
+
+ public ExternalDelegate(final Class<T> delegate, final T impl,
+ final DelegateRegistrar<T> register,
+ final DelegateRegistrar<T> unregister) {
+ this.delegate = delegate;
+ mRegister = register;
+ mUnregister = unregister;
+
+ @SuppressWarnings("unchecked")
+ final T delegateProxy = (T) Proxy.newProxyInstance(
+ getClass().getClassLoader(), impl.getClass().getInterfaces(),
+ Proxy.getInvocationHandler(mCallbackProxy));
+ mProxy = delegateProxy;
+ }
+
+ @Override
+ public int hashCode() {
+ return delegate.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof ExternalDelegate<?> &&
+ delegate.equals(((ExternalDelegate<?>) obj).delegate);
+ }
+
+ public void register() {
+ try {
+ if (!mRegistered) {
+ mRegister.invoke(mProxy);
+ mRegistered = true;
+ }
+ } catch (final Throwable e) {
+ throw unwrapRuntimeException(e);
+ }
+ }
+
+ public void unregister() {
+ try {
+ if (mRegistered) {
+ mUnregister.invoke(mProxy);
+ mRegistered = false;
+ }
+ } catch (final Throwable e) {
+ throw unwrapRuntimeException(e);
+ }
+ }
+ }
+
+ protected class CallbackDelegates {
+ private final Map<Pair<GeckoSession, Method>, MethodCall> mDelegates = new HashMap<>();
+ private final List<ExternalDelegate<?>> mExternalDelegates = new ArrayList<>();
+ private int mOrder;
+ private JSONObject mOldPrefs;
+
+ public void delegate(final @Nullable GeckoSession session,
+ final @NonNull Object callback) {
+ for (final Class<?> ifce : mAllDelegates) {
+ if (!ifce.isInstance(callback)) {
+ continue;
+ }
+ assertThat("Cannot delegate null-delegate callbacks",
+ ifce, not(isIn(mNullDelegates)));
+ addDelegatesForInterface(session, callback, ifce);
+ }
+ }
+
+ private void addDelegatesForInterface(@Nullable final GeckoSession session,
+ @NonNull final Object callback,
+ @NonNull final Class<?> ifce) {
+ for (final Method method : ifce.getMethods()) {
+ final Method callbackMethod;
+ try {
+ callbackMethod = callback.getClass().getMethod(method.getName(),
+ method.getParameterTypes());
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ final Pair<GeckoSession, Method> pair = new Pair<>(session, method);
+ final MethodCall call = new MethodCall(
+ session, callbackMethod,
+ getAssertCalled(callbackMethod, callback), callback);
+ // It's unclear if we should assert the call count if we replace an existing
+ // delegate half way through. Until that is resolved, forbid replacing an
+ // existing delegate during a test. If you are thinking about changing this
+ // behavior, first see if #delegateDuringNextWait fits your needs.
+ assertThat("Cannot replace an existing delegate",
+ mDelegates, not(hasKey(pair)));
+ mDelegates.put(pair, call);
+ }
+ }
+
+ public <T> ExternalDelegate<T> addExternalDelegate(
+ @NonNull final Class<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> unregister,
+ @NonNull final T impl) {
+ assertThat("Delegate must be an interface",
+ delegate.isInterface(), equalTo(true));
+
+ // Delegate each interface to the real thing, then register the delegate using our
+ // proxy. That way all calls to the delegate are recorded just like our internal
+ // delegates.
+ addDelegatesForInterface(/* session */ null, impl, delegate);
+
+ final ExternalDelegate<T> externalDelegate =
+ new ExternalDelegate<>(delegate, impl, register, unregister);
+ mExternalDelegates.add(externalDelegate);
+ mAllDelegates.add(delegate);
+ return externalDelegate;
+ }
+
+ @NonNull
+ public List<ExternalDelegate<?>> getExternalDelegates() {
+ return mExternalDelegates;
+ }
+
+ /** Generate a JS function to set new prefs and return a set of saved prefs. */
+ public void setPrefs(final @NonNull Map<String, ?> prefs) {
+ mOldPrefs = (JSONObject) webExtensionApiCall("SetPrefs", args -> {
+ final JSONObject existingPrefs = mOldPrefs != null ? mOldPrefs : new JSONObject();
+
+ final JSONObject newPrefs = new JSONObject();
+ for (final Map.Entry<String, ?> pref : prefs.entrySet()) {
+ final Object value = pref.getValue();
+ if (value instanceof Boolean || value instanceof Number ||
+ value instanceof CharSequence) {
+ newPrefs.put(pref.getKey(), value);
+ } else {
+ throw new IllegalArgumentException("Unsupported pref value: " + value);
+ }
+ }
+
+ args.put("oldPrefs", existingPrefs);
+ args.put("newPrefs", newPrefs);
+ });
+ }
+
+ /** Generate a JS function to set new prefs and reset a set of saved prefs. */
+ private void restorePrefs() {
+ if (mOldPrefs == null) {
+ return;
+ }
+
+ webExtensionApiCall("RestorePrefs", args -> {
+ args.put("oldPrefs", mOldPrefs);
+ mOldPrefs = null;
+ });
+ }
+
+ public void clear() {
+ for (int i = mExternalDelegates.size() - 1; i >= 0; i--) {
+ mExternalDelegates.get(i).unregister();
+ }
+ mExternalDelegates.clear();
+ mDelegates.clear();
+ mOrder = 0;
+
+ restorePrefs();
+ }
+
+ public void clearAndAssert() {
+ final Collection<MethodCall> values = mDelegates.values();
+ final MethodCall[] valuesArray = values.toArray(new MethodCall[values.size()]);
+
+ clear();
+
+ for (final MethodCall call : valuesArray) {
+ assertMatchesCount(call);
+ }
+ }
+
+ public MethodCall prepareMethodCall(final GeckoSession session, final Method method) {
+ MethodCall call = mDelegates.get(new Pair<>(session, method));
+ if (call == null && session != null) {
+ call = mDelegates.get(new Pair<>((GeckoSession) null, method));
+ }
+ if (call == null) {
+ return null;
+ }
+
+ assertAllowMoreCalls(call);
+ call.incrementCounter();
+ assertOrder(call, mOrder);
+ mOrder = Math.max(call.getOrder(), mOrder);
+ return call;
+ }
+ }
+
+ /* package */ static AssertCalled getAssertCalled(final Method method, final Object callback) {
+ final AssertCalled annotation = method.getAnnotation(AssertCalled.class);
+ if (annotation != null) {
+ return annotation;
+ }
+
+ // Some Kotlin lambdas have an invoke method that carries the annotation,
+ // instead of the interface method carrying the annotation.
+ try {
+ return callback.getClass().getDeclaredMethod(
+ "invoke", method.getParameterTypes()).getAnnotation(AssertCalled.class);
+ } catch (final NoSuchMethodException e) {
+ return null;
+ }
+ }
+
+ private static 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 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);
+ }
+
+ return new HashSet<>(list);
+ }
+
+ private static final Set<Class<?>> DEFAULT_DELEGATES = getDefaultDelegates();
+
+ public final Environment env = new Environment();
+
+ protected final Instrumentation mInstrumentation =
+ InstrumentationRegistry.getInstrumentation();
+ protected final GeckoSessionSettings mDefaultSettings;
+ protected final Set<GeckoSession> mSubSessions = new HashSet<>();
+
+ protected ErrorCollector mErrorCollector;
+ protected GeckoSession mMainSession;
+ protected Object mCallbackProxy;
+ protected Set<Class<?>> mNullDelegates;
+ protected Set<Class<?>> mAllDelegates;
+ protected List<CallRecord> mCallRecords;
+ protected CallRecordHandler mCallRecordHandler;
+ protected CallbackDelegates mWaitScopeDelegates;
+ protected CallbackDelegates mTestScopeDelegates;
+ protected int mLastWaitStart;
+ protected int mLastWaitEnd;
+ protected MethodCall mCurrentMethodCall;
+ protected long mTimeoutMillis;
+ protected Point mDisplaySize;
+ protected Map<GeckoSession, SurfaceTexture> mDisplayTextures = new HashMap<>();
+ protected Map<GeckoSession, Surface> mDisplaySurfaces = new HashMap<>();
+ protected Map<GeckoSession, GeckoDisplay> mDisplays = new HashMap<>();
+ protected boolean mClosedSession;
+ protected boolean mIgnoreCrash;
+
+ public GeckoSessionTestRule() {
+ mDefaultSettings = new GeckoSessionSettings.Builder()
+ .build();
+ }
+
+ /**
+ * Set an ErrorCollector for assertion errors, or null to not use one.
+ *
+ * @param ec ErrorCollector or null.
+ */
+ public void setErrorCollector(final @Nullable ErrorCollector ec) {
+ mErrorCollector = ec;
+ }
+
+ /**
+ * Get the current ErrorCollector, or null if not using one.
+ *
+ * @return ErrorCollector or null.
+ */
+ public @Nullable ErrorCollector getErrorCollector() {
+ return mErrorCollector;
+ }
+
+ /**
+ * Get the current timeout value in milliseconds.
+ *
+ * @return The current timeout value in milliseconds.
+ */
+ public long getTimeoutMillis() {
+ return mTimeoutMillis;
+ }
+
+ /**
+ * Assert a condition with junit.Assert or an error collector.
+ *
+ * @param reason Reason string
+ * @param value Value to check
+ * @param matcher Matcher for checking the value
+ */
+ public <T> void checkThat(final String reason, final T value, final Matcher<? super T> matcher) {
+ if (mErrorCollector != null) {
+ mErrorCollector.checkThat(reason, value, matcher);
+ } else {
+ assertThat(reason, value, matcher);
+ }
+ }
+
+ private void assertAllowMoreCalls(final MethodCall call) {
+ final int count = call.getCount();
+ if (count != -1) {
+ checkThat(call.method.getName() + " call count should be within limit",
+ call.getCurrentCount() + 1, lessThanOrEqualTo(count));
+ }
+ }
+
+ private void assertOrder(final MethodCall call, final int order) {
+ final int newOrder = call.getOrder();
+ if (newOrder != 0) {
+ checkThat(call.method.getName() + " should be in order",
+ newOrder, greaterThanOrEqualTo(order));
+ }
+ }
+
+ private void assertMatchesCount(final MethodCall call) {
+ if (call.requirement == null) {
+ return;
+ }
+ final int count = call.getCount();
+ if (count == 0) {
+ checkThat(call.method.getName() + " should not be called",
+ call.getCurrentCount(), equalTo(0));
+ } else if (count == -1) {
+ checkThat(call.method.getName() + " should be called",
+ call.getCurrentCount(), greaterThan(0));
+ } else {
+ checkThat(call.method.getName() + " should be called specified number of times",
+ call.getCurrentCount(), equalTo(count));
+ }
+ }
+
+ /**
+ * Get the session set up for the current test.
+ *
+ * @return GeckoSession object.
+ */
+ public @NonNull GeckoSession getSession() {
+ return mMainSession;
+ }
+
+ /**
+ * Get the runtime set up for the current test.
+ *
+ * @return GeckoRuntime object.
+ */
+ public @NonNull GeckoRuntime getRuntime() {
+ return RuntimeCreator.getRuntime();
+ }
+
+ public void setTelemetryDelegate(RuntimeTelemetry.Delegate delegate) {
+ RuntimeCreator.setTelemetryDelegate(delegate);
+ }
+
+ public @Nullable GeckoDisplay getDisplay() {
+ return mDisplays.get(mMainSession);
+ }
+
+ protected static Object setDelegate(final @NonNull Class<?> cls,
+ final @NonNull GeckoSession session,
+ final @Nullable Object delegate)
+ throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
+ if (cls == GeckoSession.TextInputDelegate.class) {
+ return SessionTextInput.class.getMethod("setDelegate", cls)
+ .invoke(session.getTextInput(), delegate);
+ }
+ if (cls == ContentBlocking.Delegate.class) {
+ return GeckoSession.class.getMethod("setContentBlockingDelegate", cls)
+ .invoke(session, delegate);
+ }
+ if (cls == Autofill.Delegate.class) {
+ return GeckoSession.class.getMethod("setAutofillDelegate", cls)
+ .invoke(session, delegate);
+ }
+ if (cls == MediaSession.Delegate.class) {
+ return GeckoSession.class.getMethod("setMediaSessionDelegate", cls)
+ .invoke(session, delegate);
+ }
+ return GeckoSession.class.getMethod("set" + cls.getSimpleName(), cls)
+ .invoke(session, delegate);
+ }
+
+ protected static Object getDelegate(final @NonNull Class<?> cls,
+ final @NonNull GeckoSession session)
+ throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
+ if (cls == GeckoSession.TextInputDelegate.class) {
+ return SessionTextInput.class.getMethod("getDelegate")
+ .invoke(session.getTextInput());
+ }
+ if (cls == ContentBlocking.Delegate.class) {
+ return GeckoSession.class.getMethod("getContentBlockingDelegate")
+ .invoke(session);
+ }
+ if (cls == Autofill.Delegate.class) {
+ return GeckoSession.class.getMethod("getAutofillDelegate")
+ .invoke(session);
+ }
+ if (cls == MediaSession.Delegate.class) {
+ return GeckoSession.class.getMethod("getMediaSessionDelegate")
+ .invoke(session);
+ }
+ return GeckoSession.class.getMethod("get" + cls.getSimpleName())
+ .invoke(session);
+ }
+
+ @NonNull
+ private Set<Class<?>> getCurrentDelegates() {
+ final List<ExternalDelegate<?>> waitDelegates = mWaitScopeDelegates.getExternalDelegates();
+ final List<ExternalDelegate<?>> testDelegates = mTestScopeDelegates.getExternalDelegates();
+
+ 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(DEFAULT_DELEGATES));
+ mNullDelegates.add(delegate);
+ return;
+ }
+ for (final Class<?> ifce : delegate.getInterfaces()) {
+ addNullDelegate(ifce);
+ }
+ }
+
+ protected void applyAnnotations(final Collection<Annotation> annotations,
+ final GeckoSessionSettings settings) {
+ for (final Annotation annotation : annotations) {
+ if (TimeoutMillis.class.equals(annotation.annotationType())) {
+ // Scale timeout based on the default timeout to account for the device under test.
+ final long value = ((TimeoutMillis) annotation).value();
+ final long timeout = value * env.getScaledTimeoutMillis() / Environment.DEFAULT_TIMEOUT_MILLIS;
+ mTimeoutMillis = Math.max(timeout, 1000);
+ } else if (Setting.class.equals(annotation.annotationType())) {
+ ((Setting) annotation).key().set(settings, ((Setting) annotation).value());
+ } else if (Setting.List.class.equals(annotation.annotationType())) {
+ for (final Setting setting : ((Setting.List) annotation).value()) {
+ setting.key().set(settings, setting.value());
+ }
+ } else if (NullDelegate.class.equals(annotation.annotationType())) {
+ addNullDelegate(((NullDelegate) annotation).value());
+ } else if (NullDelegate.List.class.equals(annotation.annotationType())) {
+ for (final NullDelegate nullDelegate : ((NullDelegate.List) annotation).value()) {
+ addNullDelegate(nullDelegate.value());
+ }
+ } else if (WithDisplay.class.equals(annotation.annotationType())) {
+ final WithDisplay displaySize = (WithDisplay)annotation;
+ mDisplaySize = new Point(displaySize.width(), displaySize.height());
+ } else if (ClosedSessionAtStart.class.equals(annotation.annotationType())) {
+ mClosedSession = ((ClosedSessionAtStart) annotation).value();
+ } else if (IgnoreCrash.class.equals(annotation.annotationType())) {
+ mIgnoreCrash = ((IgnoreCrash) annotation).value();
+ }
+ }
+ }
+
+ private static RuntimeException unwrapRuntimeException(final Throwable e) {
+ final Throwable cause = e.getCause();
+ if (cause != null && cause instanceof RuntimeException) {
+ return (RuntimeException) cause;
+ } else if (e instanceof RuntimeException) {
+ return (RuntimeException) e;
+ }
+
+ return new RuntimeException(cause != null ? cause : e);
+ }
+
+ protected void prepareStatement(final Description description) {
+ final GeckoSessionSettings settings = new GeckoSessionSettings(mDefaultSettings);
+ mTimeoutMillis = env.getDefaultTimeoutMillis();
+ mNullDelegates = new HashSet<>();
+ mClosedSession = false;
+ mIgnoreCrash = false;
+
+ applyAnnotations(Arrays.asList(description.getTestClass().getAnnotations()), settings);
+ applyAnnotations(description.getAnnotations(), settings);
+
+ final List<CallRecord> records = new ArrayList<>();
+ final CallbackDelegates waitDelegates = new CallbackDelegates();
+ final CallbackDelegates testDelegates = new CallbackDelegates();
+ mCallRecords = records;
+ mWaitScopeDelegates = waitDelegates;
+ mTestScopeDelegates = testDelegates;
+ mLastWaitStart = 0;
+ mLastWaitEnd = 0;
+
+ final InvocationHandler recorder = new InvocationHandler() {
+ @Override
+ public Object invoke(final Object proxy, final Method method,
+ final Object[] args) {
+ boolean ignore = false;
+ MethodCall call = null;
+
+ if (Object.class.equals(method.getDeclaringClass())) {
+ switch (method.getName()) {
+ case "equals":
+ return proxy == args[0];
+ case "toString":
+ return "Call Recorder";
+ }
+ ignore = true;
+ } else if (mCallRecordHandler != null) {
+ ignore = mCallRecordHandler.handleCall(method, args);
+ }
+
+ final boolean isExternalDelegate =
+ !DEFAULT_DELEGATES.contains(method.getDeclaringClass());
+
+ if (!ignore) {
+ if (!isExternalDelegate) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ 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];
+ }
+
+ if ((sOnCrash.equals(method) || sOnKill.equals(method))
+ && !mIgnoreCrash && isUsingSession(session)) {
+ if (env.shouldShutdownOnCrash()) {
+ getRuntime().shutdown();
+ }
+
+ throw new ChildCrashedException("Child process crashed");
+ }
+
+ records.add(new CallRecord(session, method, args));
+
+ call = waitDelegates.prepareMethodCall(session, method);
+ if (call == null) {
+ call = testDelegates.prepareMethodCall(session, method);
+ }
+
+ if (isExternalDelegate) {
+ assertThat("External delegate should be registered",
+ call, notNullValue());
+ }
+ }
+
+ Object returnValue = null;
+ try {
+ mCurrentMethodCall = call;
+ returnValue = method.invoke((call != null) ? call.target
+ : Callbacks.Default.INSTANCE, args);
+ } catch (final IllegalAccessException | InvocationTargetException e) {
+ throw unwrapRuntimeException(e);
+ } finally {
+ mCurrentMethodCall = null;
+ }
+
+ return returnValue;
+ }
+ };
+
+ final Class<?>[] classes = DEFAULT_DELEGATES.toArray(
+ new Class<?>[DEFAULT_DELEGATES.size()]);
+ mCallbackProxy = Proxy.newProxyInstance(GeckoSession.class.getClassLoader(),
+ classes, recorder);
+ mAllDelegates = new HashSet<>(DEFAULT_DELEGATES);
+
+ mMainSession = new GeckoSession(settings);
+ prepareSession(mMainSession);
+
+ if (mDisplaySize != null) {
+ addDisplay(mMainSession, mDisplaySize.x, mDisplaySize.y);
+ }
+
+ if (!mClosedSession) {
+ openSession(mMainSession);
+ UiThreadUtils.waitForCondition(() ->
+ RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL,
+ env.getDefaultTimeoutMillis());
+ if (RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_OK) {
+ throw new RuntimeException("Could not register TestSupport, see logs for error.");
+ }
+ }
+ }
+
+ protected void prepareSession(final GeckoSession session) {
+ UiThreadUtils.waitForCondition(() ->
+ RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL,
+ env.getDefaultTimeoutMillis());
+ session.getWebExtensionController()
+ .setMessageDelegate(RuntimeCreator.sTestSupportExtension,
+ mMessageDelegate,
+ "browser");
+ for (final Class<?> cls : DEFAULT_DELEGATES) {
+ try {
+ setDelegate(cls, session, mNullDelegates.contains(cls) ? null : mCallbackProxy);
+ } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * Call open() on a session, and ensure it's ready for use by the test. In particular,
+ * remove any extra calls recorded as part of opening the session.
+ *
+ * @param session Session to open.
+ */
+ public void openSession(final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ // We receive an initial about:blank load; don't expose that to the test. The initial
+ // load ends with the first onPageStop call, so ignore everything from the session
+ // until the first onPageStop call.
+
+ try {
+ // We cannot detect initial page load without progress delegate.
+ assertThat("ProgressDelegate cannot be null-delegate when opening session",
+ GeckoSession.ProgressDelegate.class, not(isIn(mNullDelegates)));
+ mCallRecordHandler = (method, args) -> {
+ Log.e(LOGTAG, "method: " + method);
+ final boolean matching = DEFAULT_DELEGATES.contains(
+ method.getDeclaringClass()) && session.equals(args[0]);
+ if (matching && sOnPageStop.equals(method)) {
+ mCallRecordHandler = null;
+ }
+ return matching;
+ };
+
+ session.open(getRuntime());
+
+ UiThreadUtils.waitForCondition(() -> mCallRecordHandler == null,
+ env.getDefaultTimeoutMillis());
+ } finally {
+ mCallRecordHandler = null;
+ }
+ }
+
+ private void waitForOpenSession(final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ // We receive an initial about:blank load; don't expose that to the test. The initial
+ // load ends with the first onPageStop call, so ignore everything from the session
+ // until the first onPageStop call.
+
+ try {
+ // We cannot detect initial page load without progress delegate.
+ assertThat("ProgressDelegate cannot be null-delegate when opening session",
+ GeckoSession.ProgressDelegate.class, not(isIn(mNullDelegates)));
+ mCallRecordHandler = (method, args) -> {
+ Log.e(LOGTAG, "method: " + method);
+ final boolean matching = DEFAULT_DELEGATES.contains(
+ method.getDeclaringClass()) && session.equals(args[0]);
+ if (matching && sOnPageStop.equals(method)) {
+ mCallRecordHandler = null;
+ }
+ return matching;
+ };
+
+ UiThreadUtils.waitForCondition(() -> mCallRecordHandler == null,
+ env.getDefaultTimeoutMillis());
+ } finally {
+ mCallRecordHandler = null;
+ }
+ }
+
+ /**
+ * Internal method to perform callback checks at the end of a test.
+ */
+ public void performTestEndCheck() {
+ mWaitScopeDelegates.clearAndAssert();
+ mTestScopeDelegates.clearAndAssert();
+ }
+
+ protected void cleanupSession(final GeckoSession session) {
+ if (session.isOpen()) {
+ session.close();
+ }
+ releaseDisplay(session);
+ }
+
+ protected boolean isUsingSession(final GeckoSession session) {
+ return session.equals(mMainSession) || mSubSessions.contains(session);
+ }
+
+ protected void deleteCrashDumps() {
+ File dumpDir = new File(getRuntime().getProfileDir(), "minidumps");
+ for (final File dump : dumpDir.listFiles()) {
+ dump.delete();
+ }
+ }
+
+ protected void cleanupExtensions() throws Throwable {
+ WebExtensionController controller = getRuntime().getWebExtensionController();
+ List<WebExtension> list = waitForResult(controller.list());
+
+ boolean hasTestSupport = false;
+ // Uninstall any left-over extensions
+ for (WebExtension extension : list) {
+ if (!extension.id.equals(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) {
+ waitForResult(controller.uninstall(extension));
+ } else {
+ hasTestSupport = true;
+ }
+ }
+
+ // If an extension was still installed, this test should fail.
+ // Note the test support extension is always kept for speed.
+ assertThat("A WebExtension was left installed during this test.",
+ list.size(), equalTo(hasTestSupport ? 1 : 0));
+ }
+
+ protected void cleanupStatement() throws Throwable {
+ mWaitScopeDelegates.clear();
+ mTestScopeDelegates.clear();
+
+ for (final GeckoSession session : mSubSessions) {
+ cleanupSession(session);
+ }
+
+ cleanupSession(mMainSession);
+ cleanupExtensions();
+
+ if (mIgnoreCrash) {
+ deleteCrashDumps();
+ }
+
+ mMainSession = null;
+ mCallbackProxy = null;
+ mAllDelegates = null;
+ mNullDelegates = null;
+ mCallRecords = null;
+ mWaitScopeDelegates = null;
+ mTestScopeDelegates = null;
+ mLastWaitStart = 0;
+ mLastWaitEnd = 0;
+ mTimeoutMillis = 0;
+ RuntimeCreator.setTelemetryDelegate(null);
+ }
+
+ @Override
+ public Statement apply(final Statement base, final Description description) {
+ return new Statement() {
+ private TestServer mServer;
+
+ private void initTest() {
+ try {
+ mServer.start(TEST_PORT);
+
+ RuntimeCreator.setPortDelegate(mPortDelegate);
+ getRuntime();
+
+ Log.e(LOGTAG, "====");
+ Log.e(LOGTAG, "before prepareStatement " + description);
+ prepareStatement(description);
+ Log.e(LOGTAG, "after prepareStatement");
+ } catch (final Throwable t) {
+ // Any error here is not related to a specific test
+ throw new TestHarnessException(t);
+ }
+ }
+
+ @Override
+ public void evaluate() throws Throwable {
+ final AtomicReference<Throwable> exceptionRef = new AtomicReference<>();
+
+ mServer = new TestServer(InstrumentationRegistry.getInstrumentation().getTargetContext());
+
+ mInstrumentation.runOnMainSync(() -> {
+ try {
+ initTest();
+ base.evaluate();
+ Log.e(LOGTAG, "after evaluate");
+ performTestEndCheck();
+ Log.e(LOGTAG, "after performTestEndCheck");
+ Log.e(LOGTAG, "====");
+ } catch (Throwable t) {
+ Log.e(LOGTAG, "====", t);
+ exceptionRef.set(t);
+ } finally {
+ try {
+ mServer.stop();
+ cleanupStatement();
+ } catch (Throwable t) {
+ exceptionRef.compareAndSet(null, t);
+ }
+ }
+ });
+
+ Throwable throwable = exceptionRef.get();
+ if (throwable != null) {
+ throw throwable;
+ }
+ }
+ };
+ }
+
+ /**
+ * This simply sends an empty message to the web content and waits for a reply.
+ */
+ public void waitForRoundTrip(final GeckoSession session) {
+ waitForJS(session, "true");
+ }
+
+ /**
+ * Wait until a page load has finished on any session. A session must have started a
+ * page load since the last wait, or this method will wait indefinitely.
+ */
+ public void waitForPageStop() {
+ waitForPageStop(/* session */ null);
+ }
+
+ /**
+ * Wait until a page load has finished. The session must have started a page load since
+ * the last wait, or this method will wait indefinitely.
+ *
+ * @param session Session to wait on, or null to wait on any session.
+ */
+ public void waitForPageStop(final GeckoSession session) {
+ waitForPageStops(session, /* count */ 1);
+ }
+
+ /**
+ * Wait until a page load has finished on any session. A session must have started a
+ * page load since the last wait, or this method will wait indefinitely.
+ *
+ * @param count Number of page loads to wait for.
+ */
+ public void waitForPageStops(final int count) {
+ waitForPageStops(/* session */ null, count);
+ }
+
+ /**
+ * Wait until a page load has finished. The session must have started a page load since
+ * the last wait, or this method will wait indefinitely.
+ *
+ * @param session Session to wait on, or null to wait on any session.
+ * @param count Number of page loads to wait for.
+ */
+ public void waitForPageStops(final GeckoSession session, final int count) {
+ final List<MethodCall> methodCalls = new ArrayList<>(1);
+ methodCalls.add(new MethodCall(session, sOnPageStop,
+ new CallRequirement(/* allowed */ true, count, null)));
+
+ waitUntilCalled(session, GeckoSession.ProgressDelegate.class, methodCalls);
+ }
+
+ /**
+ * Wait until the specified methods have been called on the specified callback
+ * interface for any session. If no methods are specified, wait until any method has
+ * been called.
+ *
+ * @param callback Target callback interface; must be an interface under GeckoSession.
+ * @param methods List of methods to wait on; use empty or null or wait on any method.
+ */
+ public void waitUntilCalled(final @NonNull KClass<?> callback,
+ final @Nullable String... methods) {
+ waitUntilCalled(/* session */ null, callback, methods);
+ }
+
+ /**
+ * Wait until the specified methods have been called on the specified callback
+ * interface. If no methods are specified, wait until any method has been called.
+ *
+ * @param session Session to wait on, or null to wait on any session.
+ * @param callback Target callback interface; must be an interface under GeckoSession.
+ * @param methods List of methods to wait on; use empty or null or wait on any method.
+ */
+ public void waitUntilCalled(final @Nullable GeckoSession session,
+ final @NonNull KClass<?> callback,
+ final @Nullable String... methods) {
+ waitUntilCalled(session, JvmClassMappingKt.getJavaClass(callback), methods);
+ }
+
+ /**
+ * Wait until the specified methods have been called on the specified callback
+ * interface for any session. If no methods are specified, wait until any method has
+ * been called.
+ *
+ * @param callback Target callback interface; must be an interface under GeckoSession.
+ * @param methods List of methods to wait on; use empty or null or wait on any method.
+ */
+ public void waitUntilCalled(final @NonNull Class<?> callback,
+ final @Nullable String... methods) {
+ waitUntilCalled(/* session */ null, callback, methods);
+ }
+
+ /**
+ * Wait until the specified methods have been called on the specified callback
+ * interface. If no methods are specified, wait until any method has been called.
+ *
+ * @param session Session to wait on, or null to wait on any session.
+ * @param callback Target callback interface; must be an interface under GeckoSession.
+ * @param methods List of methods to wait on; use empty or null or wait on any method.
+ */
+ public void waitUntilCalled(final @Nullable GeckoSession session,
+ final @NonNull Class<?> callback,
+ final @Nullable String... methods) {
+ final int length = (methods != null) ? methods.length : 0;
+ final Pattern[] patterns = new Pattern[length];
+ for (int i = 0; i < length; i++) {
+ patterns[i] = Pattern.compile(methods[i]);
+ }
+
+ final List<MethodCall> waitMethods = new ArrayList<>();
+ boolean isSessionCallback = false;
+
+ for (final Class<?> ifce : getCurrentDelegates()) {
+ if (!ifce.isAssignableFrom(callback)) {
+ continue;
+ }
+ for (final Method method : ifce.getMethods()) {
+ for (final Pattern pattern : patterns) {
+ if (!pattern.matcher(method.getName()).matches()) {
+ continue;
+ }
+ waitMethods.add(new MethodCall(session, method,
+ /* requirement */ null));
+ break;
+ }
+ }
+ isSessionCallback = true;
+ }
+
+ 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
+ * {@link AssertCalled @AssertCalled} annotations are found, wait until any method has
+ * been called. Only methods belonging to a GeckoSession callback are supported.
+ *
+ * @param callback Target callback object; must implement an interface under GeckoSession.
+ */
+ public void waitUntilCalled(final @NonNull Object callback) {
+ waitUntilCalled(/* session */ null, callback);
+ }
+
+ /**
+ * Wait until the specified methods have been called on the specified object,
+ * as specified by any {@link AssertCalled @AssertCalled} annotations. If no
+ * {@link AssertCalled @AssertCalled} annotations are found, wait until any method
+ * has been called. Only methods belonging to a GeckoSession callback are supported.
+ *
+ * @param session Session to wait on, or null to wait on any session.
+ * @param callback Target callback object; must implement an interface under GeckoSession.
+ */
+ public void waitUntilCalled(final @Nullable GeckoSession session,
+ final @NonNull Object callback) {
+ if (callback instanceof Class<?>) {
+ waitUntilCalled(session, (Class<?>) callback, (String[]) null);
+ return;
+ }
+
+ final List<MethodCall> methodCalls = new ArrayList<>();
+ boolean isSessionCallback = false;
+
+ for (final Class<?> ifce : getCurrentDelegates()) {
+ if (!ifce.isInstance(callback)) {
+ continue;
+ }
+ for (final Method method : ifce.getMethods()) {
+ final Method callbackMethod;
+ try {
+ callbackMethod = callback.getClass().getMethod(method.getName(),
+ method.getParameterTypes());
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ final AssertCalled ac = getAssertCalled(callbackMethod, callback);
+ if (ac != null && ac.value() && ac.count() != 0) {
+ 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);
+ }
+
+ private void waitUntilCalled(final @Nullable GeckoSession session,
+ final @NonNull Class<?> delegate,
+ final @NonNull List<MethodCall> methodCalls) {
+ ThreadUtils.assertOnUiThread();
+
+ if (session != null && !session.equals(mMainSession)) {
+ assertThat("Session should be wrapped through wrapSession",
+ session, isIn(mSubSessions));
+ }
+
+ // Make sure all handlers are set though #delegateUntilTestEnd or #delegateDuringNextWait,
+ // instead of through GeckoSession directly, so that we can still record calls even with
+ // custom handlers set.
+ for (final Class<?> ifce : DEFAULT_DELEGATES) {
+ final Object callback;
+ try {
+ callback = getDelegate(ifce, session == null ? mMainSession : session);
+ } catch (final NoSuchMethodException | IllegalAccessException |
+ InvocationTargetException e) {
+ throw unwrapRuntimeException(e);
+ }
+ if (mNullDelegates.contains(ifce)) {
+ // Null-delegates are initially null but are allowed to be any value.
+ continue;
+ }
+ assertThat(ifce.getSimpleName() + " callbacks should be " +
+ "accessed through GeckoSessionTestRule delegate methods",
+ callback, sameInstance(mCallbackProxy));
+ }
+
+ if (methodCalls.isEmpty()) {
+ // Waiting for any call on `delegate`; make sure it doesn't contain any null-delegates.
+ for (final Class<?> ifce : mNullDelegates) {
+ assertThat("Cannot wait on null-delegate callbacks",
+ delegate, not(typeCompatibleWith(ifce)));
+ }
+ } else {
+ // Waiting for particular calls; make sure those calls aren't from a null-delegate.
+ for (final MethodCall call : methodCalls) {
+ assertThat("Cannot wait on null-delegate callbacks",
+ call.method.getDeclaringClass(), not(isIn(mNullDelegates)));
+ }
+ }
+
+ boolean calledAny = false;
+ int index = mLastWaitEnd;
+ long startTime = SystemClock.uptimeMillis();
+
+ beforeWait();
+
+ while (!calledAny || !methodCalls.isEmpty()) {
+ final int currentIndex = index;
+
+ // Let's wait for more messages if we reached the end
+ UiThreadUtils.waitForCondition(() -> (currentIndex < mCallRecords.size()), mTimeoutMillis);
+
+ if (SystemClock.uptimeMillis() - startTime > mTimeoutMillis) {
+ throw new UiThreadUtils.TimeoutException("Timed out after " + mTimeoutMillis + "ms");
+ }
+
+ final MethodCall recorded = mCallRecords.get(index).methodCall;
+ calledAny |= recorded.method.getDeclaringClass().isAssignableFrom(delegate);
+ 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);
+ }
+ }
+
+ afterWait(index);
+ }
+
+ protected void beforeWait() {
+ mLastWaitStart = mLastWaitEnd;
+ }
+
+ protected void afterWait(final int endCallIndex) {
+ mLastWaitEnd = endCallIndex;
+ mWaitScopeDelegates.clearAndAssert();
+
+ // Register any test-delegates that were not registered due to wait-delegates
+ // having precedence.
+ for (final ExternalDelegate<?> delegate : mTestScopeDelegates.getExternalDelegates()) {
+ delegate.register();
+ }
+ }
+
+ /**
+ * Playback callbacks that were made on all sessions during the previous wait. For any
+ * methods annotated with {@link AssertCalled @AssertCalled}, assert that the
+ * callbacks satisfy the specified requirements. If no {@link AssertCalled
+ * @AssertCalled} annotations are found, assert any method has been called. Only
+ * methods belonging to a GeckoSession callback are supported.
+ *
+ * @param callback Target callback object; must implement one or more interfaces
+ * under GeckoSession.
+ */
+ public void forCallbacksDuringWait(final @NonNull Object callback) {
+ forCallbacksDuringWait(/* session */ null, callback);
+ }
+
+ /**
+ * Playback callbacks that were made during the previous wait. For any methods
+ * annotated with {@link AssertCalled @AssertCalled}, assert that the callbacks
+ * satisfy the specified requirements. If no {@link AssertCalled @AssertCalled}
+ * annotations are found, assert any method has been called. Only methods belonging
+ * to a GeckoSession callback are supported.
+ *
+ * @param session Target session object, or null to playback all sessions.
+ * @param callback Target callback object; must implement one or more interfaces
+ * under GeckoSession.
+ */
+ public void forCallbacksDuringWait(final @Nullable GeckoSession session,
+ final @NonNull Object callback) {
+ final Method[] declaredMethods = callback.getClass().getDeclaredMethods();
+ final List<MethodCall> methodCalls = new ArrayList<>(declaredMethods.length);
+ boolean assertingAnyCall = true;
+ Class<?> foundNullDelegate = null;
+
+ for (final Class<?> ifce : mAllDelegates) {
+ if (!ifce.isInstance(callback)) {
+ continue;
+ }
+ if (mNullDelegates.contains(ifce)) {
+ foundNullDelegate = ifce;
+ }
+ for (final Method method : ifce.getMethods()) {
+ final Method callbackMethod;
+ try {
+ callbackMethod = callback.getClass().getMethod(method.getName(),
+ method.getParameterTypes());
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ final MethodCall call = new MethodCall(
+ session, callbackMethod, getAssertCalled(callbackMethod, callback),
+ /* target */ null);
+ methodCalls.add(call);
+
+ if (call.requirement != null) {
+ if (foundNullDelegate == ifce) {
+ fail("Cannot assert on null-delegate " + ifce.getSimpleName());
+ }
+ assertingAnyCall = false;
+ }
+ }
+ }
+
+ if (assertingAnyCall && foundNullDelegate != null) {
+ fail("Cannot assert on null-delegate " + foundNullDelegate.getSimpleName());
+ }
+
+ int order = 0;
+ boolean calledAny = false;
+
+ for (int index = mLastWaitStart; index < mLastWaitEnd; index++) {
+ final CallRecord record = mCallRecords.get(index);
+ if (!record.method.getDeclaringClass().isInstance(callback) ||
+ (session != null && DEFAULT_DELEGATES.contains(
+ record.method.getDeclaringClass()) && !session.equals(record.args[0]))) {
+ continue;
+ }
+
+ final int i = methodCalls.indexOf(record.methodCall);
+ checkThat(record.method.getName() + " should be found",
+ i, greaterThanOrEqualTo(0));
+
+ final MethodCall methodCall = methodCalls.get(i);
+ assertAllowMoreCalls(methodCall);
+ methodCall.incrementCounter();
+ assertOrder(methodCall, order);
+ order = Math.max(methodCall.getOrder(), order);
+
+ try {
+ mCurrentMethodCall = methodCall;
+ record.method.invoke(callback, record.args);
+ } catch (final IllegalAccessException | InvocationTargetException e) {
+ throw unwrapRuntimeException(e);
+ } finally {
+ mCurrentMethodCall = null;
+ }
+ calledAny = true;
+ }
+
+ for (final MethodCall methodCall : methodCalls) {
+ assertMatchesCount(methodCall);
+ if (methodCall.requirement != null) {
+ calledAny = true;
+ }
+ }
+
+ checkThat("Should have called one of " +
+ Arrays.toString(callback.getClass().getInterfaces()),
+ calledAny, equalTo(true));
+ }
+
+ /**
+ * Get information about the current call. Only valid during a {@link
+ * #forCallbacksDuringWait}, {@link #delegateDuringNextWait}, or {@link
+ * #delegateUntilTestEnd} callback.
+ *
+ * @return Call information
+ */
+ public @NonNull CallInfo getCurrentCall() {
+ assertThat("Should be in a method call", mCurrentMethodCall, notNullValue());
+ return mCurrentMethodCall.getInfo();
+ }
+
+ /**
+ * Delegate implemented interfaces to the specified callback object for all sessions,
+ * for the rest of the test. Only GeckoSession callback interfaces are supported.
+ * Delegates for {@code delegateUntilTestEnd} can be temporarily overridden by
+ * delegates for {@link #delegateDuringNextWait}.
+ *
+ * @param callback Callback object, or null to clear all previously-set delegates.
+ */
+ public void delegateUntilTestEnd(final @NonNull Object callback) {
+ delegateUntilTestEnd(/* session */ null, callback);
+ }
+
+ /**
+ * Delegate implemented interfaces to the specified callback object, for the rest of the test.
+ * Only GeckoSession callback interfaces are supported. Delegates for {@link
+ * #delegateUntilTestEnd} can be temporarily overridden by delegates for {@link
+ * #delegateDuringNextWait}.
+ *
+ * @param session Session to target, or null to target all sessions.
+ * @param callback Callback object, or null to clear all previously-set delegates.
+ */
+ public void delegateUntilTestEnd(final @Nullable GeckoSession session,
+ final @NonNull Object callback) {
+ mTestScopeDelegates.delegate(session, callback);
+ }
+
+ /**
+ * Delegate implemented interfaces to the specified callback object for all sessions,
+ * during the next wait. Only GeckoSession callback interfaces are supported.
+ * Delegates for {@code delegateDuringNextWait} can temporarily take precedence over
+ * delegates for {@link #delegateUntilTestEnd}.
+ *
+ * @param callback Callback object, or null to clear all previously-set delegates.
+ */
+ public void delegateDuringNextWait(final @NonNull Object callback) {
+ delegateDuringNextWait(/* session */ null, callback);
+ }
+
+ /**
+ * Delegate implemented interfaces to the specified callback object, during the next wait.
+ * Only GeckoSession callback interfaces are supported. Delegates for {@link
+ * #delegateDuringNextWait} can temporarily take precedence over delegates for
+ * {@link #delegateUntilTestEnd}.
+ *
+ * @param session Session to target, or null to target all sessions.
+ * @param callback Callback object, or null to clear all previously-set delegates.
+ */
+ public void delegateDuringNextWait(final @Nullable GeckoSession session,
+ final @NonNull Object callback) {
+ mWaitScopeDelegates.delegate(session, callback);
+ }
+
+ /**
+ * Synthesize a tap event at the specified location using the main session.
+ * The session must have been created with a display.
+ *
+ * @param session Target session
+ * @param x X coordinate
+ * @param y Y coordinate
+ */
+ public void synthesizeTap(final @NonNull GeckoSession session,
+ final int x, final int y) {
+ final long downTime = SystemClock.uptimeMillis();
+ final MotionEvent down = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, x, y, 0);
+ session.getPanZoomController().onTouchEvent(down);
+
+ final MotionEvent up = MotionEvent.obtain(
+ downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0);
+ session.getPanZoomController().onTouchEvent(up);
+ }
+
+ Map<GeckoSession, WebExtension.Port> mPorts = new HashMap<>();
+
+ private WebExtension.MessageDelegate mMessageDelegate = new WebExtension.MessageDelegate() {
+ @Override
+ public void onConnect(final @NonNull WebExtension.Port port) {
+ mPorts.put(port.sender.session, port);
+ port.setDelegate(mPortDelegate);
+ }
+ };
+
+ private WebExtension.PortDelegate mPortDelegate = new WebExtension.PortDelegate() {
+ @Override
+ public void onPortMessage(@NonNull Object message, @NonNull WebExtension.Port port) {
+ JSONObject response = (JSONObject) message;
+
+ final String id;
+ try {
+ id = response.getString("id");
+ EvalJSResult result = new EvalJSResult();
+
+ final Object exception = response.get("exception");
+ if (exception != JSONObject.NULL) {
+ result.exception = exception;
+ }
+
+ final Object value = response.get("response");
+ if (value != JSONObject.NULL){
+ result.value = value;
+ }
+
+ mPendingMessages.put(id, result);
+ } catch (JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onDisconnect(final @NonNull WebExtension.Port port) {
+ mPorts.remove(port.sender.session);
+ }
+ };
+
+ private static class EvalJSResult {
+ Object value;
+ Object exception;
+ }
+
+ Map<String, EvalJSResult> mPendingMessages = new HashMap<>();
+
+ public class ExtensionPromise {
+ private UUID mUuid;
+ private GeckoSession mSession;
+
+ protected ExtensionPromise(final UUID uuid, final GeckoSession session, final String js) {
+ mUuid = uuid;
+ mSession = session;
+ evaluateJS(
+ session, "this['" + uuid + "'] = " + js + "; true"
+ );
+ }
+
+ public Object getValue() {
+ return evaluateJS(mSession, "this['" + mUuid + "']");
+ }
+ }
+
+ public ExtensionPromise evaluatePromiseJS(final @NonNull GeckoSession session,
+ final @NonNull String js) {
+ return new ExtensionPromise(UUID.randomUUID(), session, js);
+ }
+
+ public Object evaluateExtensionJS(final @NonNull String js) {
+ return webExtensionApiCall("Eval", args -> {
+ args.put("code", js);
+ });
+ }
+
+ public Object evaluateJS(final @NonNull GeckoSession session, final @NonNull String js) {
+ // Let's make sure we have the port already
+ UiThreadUtils.waitForCondition(() -> mPorts.containsKey(session), mTimeoutMillis);
+
+ final JSONObject message = new JSONObject();
+ final String id = UUID.randomUUID().toString();
+ try {
+ message.put("id", id);
+ message.put("eval", js);
+ } catch (JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+
+ mPorts.get(session).postMessage(message);
+
+ return waitForMessage(id);
+ }
+
+ public int getSessionPid(final @NonNull GeckoSession session) {
+ final Double dblPid = (Double) webExtensionApiCall(session, "GetPidForTab", null);
+ return dblPid.intValue();
+ }
+
+ public boolean getActive(final @NonNull GeckoSession session) {
+ final Boolean isActive = (Boolean)
+ webExtensionApiCall(session, "GetActive", null);
+ return isActive;
+ }
+
+ private Object waitForMessage(String id) {
+ UiThreadUtils.waitForCondition(() -> mPendingMessages.containsKey(id),
+ mTimeoutMillis);
+
+ final EvalJSResult result = mPendingMessages.get(id);
+ mPendingMessages.remove(id);
+
+ if (result.exception != null) {
+ throw new RejectedPromiseException(result.exception);
+ }
+
+ if (result.value == null) {
+ return null;
+ }
+
+ Object value;
+ try {
+ value = new JSONTokener((String) result.value).nextValue();
+ } catch (JSONException ex) {
+ value = result.value;
+ }
+
+ if (value instanceof Integer) {
+ return ((Integer) value).doubleValue();
+ }
+ return value;
+ }
+
+ /**
+ * Initialize and keep track of the specified session within the test rule. The
+ * session is automatically cleaned up at the end of the test.
+ *
+ * @param session Session to keep track of.
+ * @return Same session
+ */
+ public GeckoSession wrapSession(final GeckoSession session) {
+ try {
+ mSubSessions.add(session);
+ prepareSession(session);
+ } catch (final Throwable e) {
+ throw unwrapRuntimeException(e);
+ }
+ return session;
+ }
+
+ private GeckoSession createSession(final GeckoSessionSettings settings,
+ final boolean open) {
+ final GeckoSession session = wrapSession(new GeckoSession(settings));
+ if (open) {
+ openSession(session);
+ }
+ return session;
+ }
+
+ /**
+ * Create a new, opened session using the main session settings.
+ *
+ * @return New session.
+ */
+ public GeckoSession createOpenSession() {
+ return createSession(mMainSession.getSettings(), /* open */ true);
+ }
+
+ /**
+ * Create a new, opened session using the specified settings.
+ *
+ * @param settings Settings for the new session.
+ * @return New session.
+ */
+ public GeckoSession createOpenSession(final GeckoSessionSettings settings) {
+ return createSession(settings, /* open */ true);
+ }
+
+ /**
+ * Create a new, closed session using the specified settings.
+ *
+ * @return New session.
+ */
+ public GeckoSession createClosedSession() {
+ return createSession(mMainSession.getSettings(), /* open */ false);
+ }
+
+ /**
+ * Create a new, closed session using the specified settings.
+ *
+ * @param settings Settings for the new session.
+ * @return New session.
+ */
+ public GeckoSession createClosedSession(final GeckoSessionSettings settings) {
+ return createSession(settings, /* open */ false);
+ }
+
+ /**
+ * Return a value from the given array indexed by the current call counter. Only valid
+ * during a {@link #forCallbacksDuringWait}, {@link #delegateDuringNextWait}, or
+ * {@link #delegateUntilTestEnd} callback.
+ * <p><p>
+ * Asserts that {@code foo} is equal to {@code "bar"} during the first call and {@code
+ * "baz"} during the second call:
+ * <pre>{@code assertThat("Foo should match", foo, equalTo(forEachCall("bar",
+ * "baz")));}</pre>
+ *
+ * @param values Input array
+ * @return Value from input array indexed by the current call counter.
+ */
+ @SafeVarargs
+ public final <T> T forEachCall(T... values) {
+ assertThat("Should be in a method call", mCurrentMethodCall, notNullValue());
+ return values[Math.min(mCurrentMethodCall.getCurrentCount(), values.length) - 1];
+ }
+
+ /**
+ * Evaluate a JavaScript expression and return the result, similar to {@link #evaluateJS}.
+ * In addition, treat the evaluation as a wait event, which will affect other calls such as
+ * {@link #forCallbacksDuringWait}. If the result is a Promise, wait on the Promise to settle
+ * and return or throw based on the outcome.
+ *
+ * @param session Session containing the target page.
+ * @param js JavaScript expression.
+ * @return Result of the expression or value of the resolved Promise.
+ * @see #evaluateJS
+ */
+ public @Nullable Object waitForJS(final @NonNull GeckoSession session, final @NonNull String js) {
+ try {
+ beforeWait();
+ return evaluateJS(session, js);
+ } finally {
+ afterWait(mCallRecords.size());
+ }
+ }
+
+ /**
+ * Get a list of Gecko prefs. Undefined prefs will return as null.
+ *
+ * @param prefs List of pref names.
+ * @return Pref values as a list of values.
+ */
+ public JSONArray getPrefs(final @NonNull String... prefs) {
+ return (JSONArray) webExtensionApiCall("GetPrefs", args -> {
+ args.put("prefs", new JSONArray(Arrays.asList(prefs)));
+ });
+ }
+
+ /**
+ * Gets the color of a link for a given URI and selector.
+ *
+ * @param uri Page where the link is present.
+ * @param selector Selector that matches the link
+ * @return String representing the color, e.g. rgb(0, 0, 255)
+ */
+ public String getLinkColor(final String uri, final String selector) {
+ return (String) webExtensionApiCall("GetLinkColor", args -> {
+ args.put("uri", uri);
+ args.put("selector", selector);
+ });
+ }
+
+ public List<String> getRequestedLocales() {
+ try {
+ JSONArray locales = (JSONArray) webExtensionApiCall("GetRequestedLocales", null);
+ List<String> result = new ArrayList<>();
+
+ for (int i = 0; i < locales.length(); i++) {
+ result.add(locales.getString(i));
+ }
+
+ return result;
+ } catch (JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ /**
+ * Adds value to the given histogram.
+ *
+ * @param id the histogram id to increment.
+ * @param value to add to the histogram.
+ */
+ public void addHistogram(final String id, final long value) {
+ webExtensionApiCall("AddHistogram", args -> {
+ args.put("id", id);
+ args.put("value", value);
+ });
+ }
+
+ /**
+ * Revokes SSL overrides set for a given host and port
+ *
+ * @param host the host.
+ * @param port the port (-1 == 443).
+ */
+ public void removeCertOverride(final String host, final long port) {
+ webExtensionApiCall("RemoveCertOverride", args -> {
+ args.put("host", host);
+ args.put("port", port);
+ });
+ }
+
+ private interface SetArgs {
+ void setArgs(JSONObject object) throws JSONException;
+ }
+
+ /**
+ * Sets value to the given scalar.
+ *
+ * @param id the scalar to be set.
+ * @param value the value to set.
+ */
+ public <T> void setScalar(final String id, final T value) {
+ webExtensionApiCall("SetScalar", args -> {
+ args.put("id", id);
+ args.put("value", value);
+ });
+ }
+
+ /**
+ * Invokes nsIDOMWindowUtils.setResolutionAndScaleTo.
+ */
+ public void setResolutionAndScaleTo(final float resolution) {
+ webExtensionApiCall("SetResolutionAndScaleTo", args -> {
+ args.put("resolution", resolution);
+ });
+ }
+
+ /**
+ * Invokes nsIDOMWindowUtils.flushApzRepaints.
+ */
+ public void flushApzRepaints(final GeckoSession session) {
+ webExtensionApiCall(session, "FlushApzRepaints", null);
+ }
+
+ private Object webExtensionApiCall(final @NonNull String apiName, final @NonNull SetArgs argsSetter) {
+ return webExtensionApiCall(null, apiName, argsSetter);
+ }
+
+ private Object webExtensionApiCall(final GeckoSession session, final @NonNull String apiName,
+ final @NonNull SetArgs argsSetter) {
+ // Ensure background script is connected
+ UiThreadUtils.waitForCondition(() -> RuntimeCreator.backgroundPort() != null,
+ mTimeoutMillis);
+
+ if (session != null) {
+ // Ensure content script is connected
+ UiThreadUtils.waitForCondition(() -> mPorts.get(session) != null,
+ mTimeoutMillis);
+ }
+
+ final String id = UUID.randomUUID().toString();
+
+ final JSONObject message = new JSONObject();
+
+ try {
+ final JSONObject args = new JSONObject();
+ if (argsSetter != null) {
+ argsSetter.setArgs(args);
+ }
+
+ message.put("id", id);
+ message.put("type", apiName);
+ message.put("args", args);
+ } catch (JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+
+ if (session == null) {
+ RuntimeCreator.backgroundPort().postMessage(message);
+ } else {
+ // We post the message using session's port instead of the background port. By routing
+ // the message through the extension's content script, we are able to obtain and attach
+ // the session's WebExtension tab as a `tab` argument to the API.
+ mPorts.get(session).postMessage(message);
+ }
+
+ return waitForMessage(id);
+ }
+
+ /**
+ * Set a list of Gecko prefs for the rest of the test. Prefs set in {@link #setPrefsDuringNextWait} can
+ * temporarily take precedence over prefs set in {@code setPrefsUntilTestEnd}.
+ *
+ * @param prefs Map of pref names to values.
+ * @see #setPrefsDuringNextWait
+ */
+ public void setPrefsUntilTestEnd(final @NonNull Map<String, ?> prefs) {
+ mTestScopeDelegates.setPrefs(prefs);
+ }
+
+ /**
+ * Set a list of Gecko prefs during the next wait. Prefs set in {@code setPrefsDuringNextWait} can
+ * temporarily take precedence over prefs set in {@link #setPrefsUntilTestEnd}.
+ *
+ * @param prefs Map of pref names to values.
+ * @see #setPrefsUntilTestEnd
+ */
+ public void setPrefsDuringNextWait(final @NonNull Map<String, ?> prefs) {
+ mWaitScopeDelegates.setPrefs(prefs);
+ }
+
+ /**
+ * Register an external, non-GeckoSession delegate, and start recording the delegate calls
+ * until the end of the test. The delegate can then be used with methods such as {@link
+ * #waitUntilCalled(Class, String...)} and {@link #forCallbacksDuringWait(Object)}. At the
+ * end of the test, the delegate is automatically unregistered. Delegates added by {@link
+ * #addExternalDelegateDuringNextWait} can temporarily take precedence over delegates added
+ * by {@code delegateUntilTestEnd}.
+ *
+ * @param delegate Delegate instance to register.
+ * @param register DelegateRegistrar instance that represents a function to register the
+ * delegate.
+ * @param unregister DelegateRegistrar instance that represents a function to unregister the
+ * delegate.
+ * @param impl Default delegate implementation. Its methods may be annotated with
+ * {@link AssertCalled} annotations to assert expected behavior.
+ * @see #addExternalDelegateDuringNextWait
+ */
+ public <T> void addExternalDelegateUntilTestEnd(@NonNull final Class<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> unregister,
+ @NonNull final T impl) {
+ final ExternalDelegate<T> externalDelegate =
+ mTestScopeDelegates.addExternalDelegate(delegate, register, unregister, impl);
+
+ // Register if there is not a wait delegate to take precedence over this call.
+ if (!mWaitScopeDelegates.getExternalDelegates().contains(externalDelegate)) {
+ externalDelegate.register();
+ }
+ }
+
+ /** @see #addExternalDelegateUntilTestEnd(Class, DelegateRegistrar,
+ * DelegateRegistrar, Object) */
+ public <T> void addExternalDelegateUntilTestEnd(@NonNull final KClass<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> unregister,
+ @NonNull final T impl) {
+ addExternalDelegateUntilTestEnd(JvmClassMappingKt.getJavaClass(delegate),
+ register, unregister, impl);
+ }
+
+ /**
+ * Register an external, non-GeckoSession delegate, and start recording the delegate calls
+ * during the next wait. The delegate can then be used with methods such as {@link
+ * #waitUntilCalled(Class, String...)} and {@link #forCallbacksDuringWait(Object)}. After the
+ * next wait, the delegate is automatically unregistered. Delegates added by {@code
+ * addExternalDelegateDuringNextWait} can temporarily take precedence over delegates added
+ * by {@link #delegateUntilTestEnd}.
+ *
+ * @param delegate Delegate instance to register.
+ * @param register DelegateRegistrar instance that represents a function to register the
+ * delegate.
+ * @param unregister DelegateRegistrar instance that represents a function to unregister the
+ * delegate.
+ * @param impl Default delegate implementation. Its methods may be annotated with
+ * {@link AssertCalled} annotations to assert expected behavior.
+ * @see #addExternalDelegateDuringNextWait
+ */
+ public <T> void addExternalDelegateDuringNextWait(@NonNull final Class<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> unregister,
+ @NonNull final T impl) {
+ final ExternalDelegate<T> externalDelegate =
+ mWaitScopeDelegates.addExternalDelegate(delegate, register, unregister, impl);
+
+ // Always register because this call always takes precedence, but make sure to unregister
+ // any test-delegates first.
+ final int index = mTestScopeDelegates.getExternalDelegates().indexOf(externalDelegate);
+ if (index >= 0) {
+ mTestScopeDelegates.getExternalDelegates().get(index).unregister();
+ }
+ externalDelegate.register();
+ }
+
+ /** @see #addExternalDelegateDuringNextWait(Class, DelegateRegistrar,
+ * DelegateRegistrar, Object) */
+ public <T> void addExternalDelegateDuringNextWait(@NonNull final KClass<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> unregister,
+ @NonNull final T impl) {
+ addExternalDelegateDuringNextWait(JvmClassMappingKt.getJavaClass(delegate),
+ register, unregister, impl);
+ }
+
+ /**
+ * This waits for the given result and returns it's value. If
+ * the result failed with an exception, it is rethrown.
+ *
+ * @param result A {@link GeckoResult} instance.
+ * @param <T> The type of the value held by the {@link GeckoResult}
+ * @return The value of the completed {@link GeckoResult}.
+ */
+ public <T> T waitForResult(@NonNull GeckoResult<T> result) throws Throwable {
+ beforeWait();
+ try {
+ return UiThreadUtils.waitForResult(result, mTimeoutMillis);
+ } catch (final Throwable e) {
+ throw unwrapRuntimeException(e);
+ } finally {
+ afterWait(mCallRecords.size());
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java
new file mode 100644
index 0000000000..a9ca43b085
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java
@@ -0,0 +1,10 @@
+package org.mozilla.geckoview.test.rule;
+
+/**
+ * Exception thrown when an error occurs in the test harness itself and not in a specific test
+ */
+public class TestHarnessException extends RuntimeException {
+ public TestHarnessException(final Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
new file mode 100644
index 0000000000..636e7d1b1c
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
@@ -0,0 +1,65 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test.util
+
+import org.mozilla.geckoview.AllowOrDeny
+import org.mozilla.geckoview.Autofill
+import org.mozilla.geckoview.ContentBlocking
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.MediaElement
+import org.mozilla.geckoview.MediaSession
+import org.mozilla.geckoview.WebRequestError
+
+import android.view.inputmethod.CursorAnchorInfo
+import android.view.inputmethod.ExtractedText
+import android.view.inputmethod.ExtractedTextRequest
+import org.json.JSONObject
+
+class Callbacks private constructor() {
+ object Default : All
+
+ interface All : AutofillDelegate, ContentBlockingDelegate, ContentDelegate,
+ HistoryDelegate, MediaDelegate, MediaSessionDelegate,
+ NavigationDelegate, PermissionDelegate, ProgressDelegate,
+ PromptDelegate, ScrollDelegate, SelectionActionDelegate,
+ TextInputDelegate
+
+ interface AutofillDelegate : Autofill.Delegate {}
+ interface ContentDelegate : GeckoSession.ContentDelegate {}
+ interface NavigationDelegate : GeckoSession.NavigationDelegate {}
+ interface PermissionDelegate : GeckoSession.PermissionDelegate {}
+ interface ProgressDelegate : GeckoSession.ProgressDelegate {}
+ interface PromptDelegate : GeckoSession.PromptDelegate {}
+ interface ScrollDelegate : GeckoSession.ScrollDelegate {}
+ interface ContentBlockingDelegate : ContentBlocking.Delegate {}
+ interface SelectionActionDelegate : GeckoSession.SelectionActionDelegate {}
+ interface MediaDelegate: GeckoSession.MediaDelegate {}
+ interface HistoryDelegate : GeckoSession.HistoryDelegate {}
+ interface MediaSessionDelegate: MediaSession.Delegate {}
+
+ interface TextInputDelegate : GeckoSession.TextInputDelegate {
+ override fun restartInput(session: GeckoSession, reason: Int) {
+ }
+
+ override fun showSoftInput(session: GeckoSession) {
+ }
+
+ override fun hideSoftInput(session: GeckoSession) {
+ }
+
+ override fun updateSelection(session: GeckoSession, selStart: Int, selEnd: Int, compositionStart: Int, compositionEnd: Int) {
+ }
+
+ override fun updateExtractedText(session: GeckoSession, request: ExtractedTextRequest, text: ExtractedText) {
+ }
+
+ override fun updateCursorAnchorInfo(session: GeckoSession, info: CursorAnchorInfo) {
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java
new file mode 100644
index 0000000000..7eafc6802f
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java
@@ -0,0 +1,85 @@
+package org.mozilla.geckoview.test.util;
+
+import org.mozilla.geckoview.BuildConfig;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Debug;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+public class Environment {
+ public static final long DEFAULT_TIMEOUT_MILLIS = 30000;
+ public static final long DEFAULT_ARM_DEVICE_TIMEOUT_MILLIS = 30000;
+ public static final long DEFAULT_ARM_EMULATOR_TIMEOUT_MILLIS = 120000;
+ public static final long DEFAULT_X86_DEVICE_TIMEOUT_MILLIS = 30000;
+ public static final long DEFAULT_X86_EMULATOR_TIMEOUT_MILLIS = 30000;
+ public static final long DEFAULT_IDE_DEBUG_TIMEOUT_MILLIS = 86400000;
+
+ private String getEnvVar(final String name) {
+ final int nameLen = name.length();
+ final Bundle args = InstrumentationRegistry.getArguments();
+ String env = args.getString("env0", null);
+ for (int i = 1; env != null; i++) {
+ if (env.length() >= nameLen + 1 &&
+ env.startsWith(name) &&
+ env.charAt(nameLen) == '=') {
+ return env.substring(nameLen + 1);
+ }
+ env = args.getString("env" + i, null);
+ }
+ return "";
+ }
+
+ public boolean isAutomation() {
+ return !getEnvVar("MOZ_IN_AUTOMATION").isEmpty();
+ }
+
+ public boolean shouldShutdownOnCrash() {
+ return !getEnvVar("MOZ_CRASHREPORTER_SHUTDOWN").isEmpty();
+ }
+
+ public boolean isDebugging() {
+ return Debug.isDebuggerConnected();
+ }
+
+ public boolean isEmulator() {
+ return "generic".equals(Build.DEVICE) || Build.DEVICE.startsWith("generic_");
+ }
+
+ public boolean isDebugBuild() {
+ return BuildConfig.DEBUG_BUILD;
+ }
+
+ public boolean isX86() {
+ final String abi;
+ if (Build.VERSION.SDK_INT >= 21) {
+ abi = Build.SUPPORTED_ABIS[0];
+ } else {
+ abi = Build.CPU_ABI;
+ }
+
+ return abi.startsWith("x86");
+ }
+
+ public boolean isFission() {
+ return getEnvVar("MOZ_FORCE_ENABLE_FISSION").equals("1");
+ }
+
+ public boolean isWebrender() {
+ return getEnvVar("MOZ_WEBRENDER").equals("1");
+ }
+
+ public long getScaledTimeoutMillis() {
+ if (isX86()) {
+ return isEmulator() ? DEFAULT_X86_EMULATOR_TIMEOUT_MILLIS
+ : DEFAULT_X86_DEVICE_TIMEOUT_MILLIS;
+ }
+ return isEmulator() ? DEFAULT_ARM_EMULATOR_TIMEOUT_MILLIS
+ : DEFAULT_ARM_DEVICE_TIMEOUT_MILLIS;
+ }
+
+ public long getDefaultTimeoutMillis() {
+ return isDebugging() ? DEFAULT_IDE_DEBUG_TIMEOUT_MILLIS
+ : getScaledTimeoutMillis();
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java
new file mode 100644
index 0000000000..78aac79a84
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java
@@ -0,0 +1,251 @@
+package org.mozilla.geckoview.test.util;
+
+import org.mozilla.geckoview.ContentBlocking;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoRuntimeSettings;
+import org.mozilla.geckoview.RuntimeTelemetry;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.test.TestCrashHandler;
+
+import static org.mozilla.geckoview.ContentBlocking.SafeBrowsingProvider;
+
+import android.os.Looper;
+import android.os.Process;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.test.platform.app.InstrumentationRegistry;
+import android.util.Log;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+
+public class RuntimeCreator {
+ public static final int TEST_SUPPORT_INITIAL = 0;
+ public static final int TEST_SUPPORT_OK = 1;
+ public static final int TEST_SUPPORT_ERROR = 2;
+ public static final String TEST_SUPPORT_EXTENSION_ID = "test-support@tests.mozilla.org";
+ private static final String LOGTAG = "RuntimeCreator";
+
+ private static final Environment env = new Environment();
+ private static GeckoRuntime sRuntime;
+ public static AtomicInteger sTestSupport = new AtomicInteger(0);
+ public static WebExtension sTestSupportExtension;
+
+ // The RuntimeTelemetry.Delegate can only be set when creating the RuntimeCreator, to
+ // let tests set their own Delegate we need to create a proxy here.
+ public static class RuntimeTelemetryDelegate implements RuntimeTelemetry.Delegate {
+ public RuntimeTelemetry.Delegate delegate = null;
+
+ @Override
+ public void onHistogram(@NonNull RuntimeTelemetry.Histogram metric) {
+ if (delegate != null) {
+ delegate.onHistogram(metric);
+ }
+ }
+
+ @Override
+ public void onBooleanScalar(@NonNull RuntimeTelemetry.Metric<Boolean> metric) {
+ if (delegate != null) {
+ delegate.onBooleanScalar(metric);
+ }
+ }
+
+ @Override
+ public void onStringScalar(@NonNull RuntimeTelemetry.Metric<String> metric) {
+ if (delegate != null) {
+ delegate.onStringScalar(metric);
+ }
+ }
+
+ @Override
+ public void onLongScalar(@NonNull RuntimeTelemetry.Metric<Long> metric) {
+ if (delegate != null) {
+ delegate.onLongScalar(metric);
+ }
+ }
+ }
+
+ public static final RuntimeTelemetryDelegate sRuntimeTelemetryProxy =
+ new RuntimeTelemetryDelegate();
+
+ private static WebExtension.Port sBackgroundPort;
+
+ private static WebExtension.PortDelegate sPortDelegate;
+
+ private static WebExtension.MessageDelegate sMessageDelegate
+ = new WebExtension.MessageDelegate() {
+ @Nullable
+ @Override
+ public void onConnect(@NonNull WebExtension.Port port) {
+ sBackgroundPort = port;
+ port.setDelegate(sWrapperPortDelegate);
+ }
+ };
+
+ private static WebExtension.PortDelegate sWrapperPortDelegate = new WebExtension.PortDelegate() {
+ @Override
+ public void onPortMessage(@NonNull Object message, @NonNull WebExtension.Port port) {
+ if (sPortDelegate != null) {
+ sPortDelegate.onPortMessage(message, port);
+ }
+ }
+ };
+
+ public static WebExtension.Port backgroundPort() {
+ return sBackgroundPort;
+ }
+
+ public static void registerTestSupport() {
+ sTestSupport.set(0);
+
+ sRuntime.getWebExtensionController().installBuiltIn(
+ "resource://android/assets/web_extensions/test-support/").accept(extension -> {
+ extension.setMessageDelegate(sMessageDelegate, "browser");
+ sTestSupportExtension = extension;
+ sTestSupport.set(TEST_SUPPORT_OK);
+ }, exception -> {
+ Log.e(LOGTAG, "Could not register TestSupport", exception);
+ sTestSupport.set(TEST_SUPPORT_ERROR);
+ });
+ }
+
+ /**
+ * Set the {@link RuntimeTelemetry.Delegate} instance for this test. Application code can only
+ * register this delegate when the {@link GeckoRuntime} is created, so we need to proxy it
+ * for test code.
+ *
+ * @param delegate the {@link RuntimeTelemetry.Delegate} for this test run.
+ */
+ public static void setTelemetryDelegate(RuntimeTelemetry.Delegate delegate) {
+ sRuntimeTelemetryProxy.delegate = delegate;
+ }
+
+ public static void setPortDelegate(WebExtension.PortDelegate portDelegate) {
+ sPortDelegate = portDelegate;
+ }
+
+ private static GeckoRuntime.Delegate sShutdownDelegate;
+
+ private static GeckoRuntime.Delegate sWrapperShutdownDelegate = new GeckoRuntime.Delegate() {
+ @Override
+ public void onShutdown() {
+ if (sShutdownDelegate != null) {
+ sShutdownDelegate.onShutdown();
+ return;
+ }
+
+ Process.killProcess(Process.myPid());
+ }
+ };
+
+ @UiThread
+ public static GeckoRuntime getRuntime() {
+ if (sRuntime != null) {
+ return sRuntime;
+ }
+
+ final SafeBrowsingProvider googleLegacy = SafeBrowsingProvider
+ .from(ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing-dummy/update")
+ .build();
+
+ final SafeBrowsingProvider google = SafeBrowsingProvider
+ .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing4-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing4-dummy/update")
+ .build();
+
+ final GeckoRuntimeSettings runtimeSettings = new GeckoRuntimeSettings.Builder()
+ .contentBlocking(new ContentBlocking.Settings.Builder()
+ .safeBrowsingProviders(googleLegacy, google)
+ .build())
+ .arguments(new String[]{"-purgecaches"})
+ .extras(InstrumentationRegistry.getArguments())
+ .remoteDebuggingEnabled(true)
+ .consoleOutput(true)
+ .crashHandler(TestCrashHandler.class)
+ .telemetryDelegate(sRuntimeTelemetryProxy)
+ .build();
+
+ sRuntime = GeckoRuntime.create(
+ InstrumentationRegistry.getInstrumentation().getTargetContext(),
+ runtimeSettings);
+
+ registerTestSupport();
+
+ sRuntime.setDelegate(sWrapperShutdownDelegate);
+
+ return sRuntime;
+ }
+
+ private static final class ShutdownCompleteIndicator implements GeckoRuntime.Delegate {
+ private boolean mDone = false;
+
+ @Override
+ public void onShutdown() {
+ mDone = true;
+ }
+
+ public boolean isDone() {
+ return mDone;
+ }
+ }
+
+ @UiThread
+ private static void shutdownRuntimeInternal(final long timeoutMillis) {
+ if (sRuntime == null) {
+ return;
+ }
+
+ final ShutdownCompleteIndicator indicator = new ShutdownCompleteIndicator();
+ sShutdownDelegate = indicator;
+
+ sRuntime.shutdown();
+
+ UiThreadUtils.waitForCondition(() -> indicator.isDone(), timeoutMillis);
+ if (!indicator.isDone()) {
+ throw new RuntimeException("Timed out waiting for GeckoRuntime shutdown to complete");
+ }
+
+ sRuntime = null;
+ sShutdownDelegate = null;
+ }
+
+ /**
+ * ParentCrashTest needs to start a GeckoRuntime inside a separate service in a separate
+ * process from this one. Unfortunately that does not play well with the GeckoRuntime in this
+ * process, since as far as Android is concerned, they are both running inside the same
+ * Application.
+ *
+ * Any test that starts its own GeckoRuntime should call this method during its setup to shut
+ * down any extant GeckoRuntime, thus ensuring only one GeckoRuntime is active at once.
+ */
+ public static void shutdownRuntime() {
+ // It takes a while to shutdown an existing runtime in debug builds, so
+ // we double the timeout for this method.
+ final long timeoutMillis = 2 * env.getDefaultTimeoutMillis();
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ shutdownRuntimeInternal(timeoutMillis);
+ return;
+ }
+
+ final Runnable runnable = new Runnable() {
+ @Override
+ public void run() {
+ RuntimeCreator.shutdownRuntimeInternal(timeoutMillis);
+ }
+ };
+
+ FutureTask<Void> task = new FutureTask<>(runnable, null);
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(task);
+ try {
+ task.get(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (Throwable e) {
+ throw new RuntimeException(e.toString());
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt
new file mode 100644
index 0000000000..70bc2a027a
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt
@@ -0,0 +1,167 @@
+package org.mozilla.geckoview.test.util
+
+import android.content.Context
+import android.content.res.AssetManager
+import android.os.SystemClock
+import android.webkit.MimeTypeMap
+import com.koushikdutta.async.ByteBufferList
+import com.koushikdutta.async.http.server.AsyncHttpServer
+import com.koushikdutta.async.http.server.AsyncHttpServerRequest
+import com.koushikdutta.async.http.server.AsyncHttpServerResponse
+import com.koushikdutta.async.util.TaggedList
+import org.json.JSONObject
+import java.io.FileNotFoundException
+import java.math.BigInteger
+import java.security.MessageDigest
+import java.util.*
+
+class TestServer {
+ private val server = AsyncHttpServer()
+ private val assets: AssetManager
+ private val stallingResponses = Vector<AsyncHttpServerResponse>()
+
+ constructor(context: Context) {
+ assets = context.resources.assets
+
+ val anything = { request: AsyncHttpServerRequest, response: AsyncHttpServerResponse ->
+ val obj = JSONObject()
+
+ obj.put("method", request.method)
+
+ val headers = JSONObject()
+ for (key in request.headers.multiMap.keys) {
+ val values = request.headers.multiMap.get(key) as TaggedList<String>
+ headers.put(values.tag(), values.joinToString(", "))
+ }
+
+ obj.put("headers", headers)
+
+ if (request.method == "POST") {
+ obj.put("data", request.body.get() as String)
+ }
+
+ response.send(obj)
+ }
+
+ server.post("/anything", anything)
+ server.get("/anything", anything)
+
+ server.get("/assets/.*") { request, response ->
+ try {
+ val mimeType = MimeTypeMap.getSingleton()
+ .getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(request.path))
+ val name = request.path.substring("/assets/".count())
+ val asset = assets.open(name).readBytes()
+
+ response.send(mimeType, asset)
+ } catch (e: FileNotFoundException) {
+ response.code(404)
+ response.end()
+ }
+ }
+
+ server.get("/status/.*") { request, response ->
+ val statusCode = request.path.substring("/status/".count()).toInt()
+ response.code(statusCode)
+ response.end()
+ }
+
+ server.get("/redirect-to.*") { request, response ->
+ response.redirect(request.query.getString("url"))
+ }
+
+ server.get("/redirect/.*") { request, response ->
+ val count = request.path.split('/').last().toInt() - 1
+ if (count > 0) {
+ response.redirect("/redirect/${count}")
+ }
+
+ response.end()
+ }
+
+ server.get("/basic-auth/.*") { _, response ->
+ response.code(401)
+ response.headers.set("WWW-Authenticate", "Basic realm=\"Fake Realm\"")
+ response.end()
+ }
+
+ server.get("/cookies") { request, response ->
+ val cookiesObj = JSONObject()
+
+ request.headers.get("cookie")?.split(";")?.forEach {
+ val parts = it.trim().split('=')
+ cookiesObj.put(parts[0], parts[1])
+ }
+
+ val obj = JSONObject()
+ obj.put("cookies", cookiesObj)
+ response.send(obj)
+ }
+
+ server.get("/cookies/set/.*") { request, response ->
+ val parts = request.path.substring("/cookies/set/".count()).split('/')
+
+ response.headers.set("Set-Cookie", "${parts[0]}=${parts[1]}; Path=/")
+ response.headers.set("Location", "/cookies")
+ response.code(302)
+ response.end()
+ }
+
+ server.get("/bytes/.*") { request, response ->
+ val count = request.path.split("/").last().toInt()
+ val random = Random(System.currentTimeMillis())
+ val payload = ByteArray(count)
+ random.nextBytes(payload)
+
+ val digest = MessageDigest.getInstance("SHA-256").digest(payload)
+ response.headers.set("X-SHA-256", String.format("%064x", BigInteger(1, digest)))
+ response.send("application/octet-stream", payload)
+ }
+
+ server.get("/trickle/.*") { request, response ->
+ val count = request.path.split("/").last().toInt()
+
+ response.setContentType("application/octet-stream")
+ response.headers.set("Content-Length", "${count}")
+ response.writeHead()
+
+ val payload = byteArrayOf(1)
+ for (i in 1..count) {
+ response.write(ByteBufferList(payload))
+ SystemClock.sleep(250)
+ }
+
+ response.end()
+ }
+
+ server.get("/stall/.*") { _, response ->
+ // keep trickling data for a long time (until we are stopped)
+ stallingResponses.add(response)
+
+ val count = 100
+ response.setContentType("InstallException")
+ response.headers.set("Content-Length", "${count}")
+ response.writeHead()
+
+ val payload = byteArrayOf(1)
+ for (i in 1..count - 1) {
+ response.write(ByteBufferList(payload))
+ SystemClock.sleep(250)
+ }
+
+ stallingResponses.remove(response)
+ response.end()
+ }
+ }
+
+ fun start(port: Int) {
+ server.listen(port)
+ }
+
+ fun stop() {
+ for (response in stallingResponses) {
+ response.end()
+ }
+ server.stop()
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java
new file mode 100644
index 0000000000..4cb0dca7b0
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java
@@ -0,0 +1,164 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test.util;
+
+import org.mozilla.geckoview.GeckoResult;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import androidx.annotation.NonNull;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class UiThreadUtils {
+ private static Method sGetNextMessage = null;
+ static {
+ try {
+ sGetNextMessage = MessageQueue.class.getDeclaredMethod("next");
+ sGetNextMessage.setAccessible(true);
+ } catch (NoSuchMethodException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ public static class TimeoutException extends RuntimeException {
+ public TimeoutException(final String detailMessage) {
+ super(detailMessage);
+ }
+ }
+
+ private static final class TimeoutRunnable implements Runnable {
+ private long timeout;
+
+ public void set(final long timeout) {
+ this.timeout = timeout;
+ cancel();
+ HANDLER.postDelayed(this, timeout);
+ }
+
+ public void cancel() {
+ HANDLER.removeCallbacks(this);
+ }
+
+ @Override
+ public void run() {
+ throw new TimeoutException("Timed out after " + timeout + "ms");
+ }
+ }
+
+ public static final Handler HANDLER = new Handler(Looper.getMainLooper());
+ private static final TimeoutRunnable TIMEOUT_RUNNABLE = new TimeoutRunnable();
+ private static RuntimeException unwrapRuntimeException(final Throwable e) {
+ final Throwable cause = e.getCause();
+ if (cause != null && cause instanceof RuntimeException) {
+ return (RuntimeException) cause;
+ } else if (e instanceof RuntimeException) {
+ return (RuntimeException) e;
+ }
+
+ return new RuntimeException(cause != null ? cause : e);
+ }
+
+ /**
+ * This waits for the given result and returns it's value. If
+ * the result failed with an exception, it is rethrown.
+ *
+ * @param result A {@link GeckoResult} instance.
+ * @param <T> The type of the value held by the {@link GeckoResult}
+ * @return The value of the completed {@link GeckoResult}.
+ */
+ public static <T> T waitForResult(@NonNull GeckoResult<T> result, long timeout) throws Throwable {
+ final ResultHolder<T> holder = new ResultHolder<>(result);
+
+ waitForCondition(() -> holder.isComplete, timeout);
+
+ if (holder.error != null) {
+ throw holder.error;
+ }
+
+ return holder.value;
+ }
+
+ private static class ResultHolder<T> {
+ public T value;
+ public Throwable error;
+ public boolean isComplete;
+
+ public ResultHolder(GeckoResult<T> result) {
+ result.accept(value -> {
+ ResultHolder.this.value = value;
+ isComplete = true;
+ }, error -> {
+ ResultHolder.this.error = error;
+ isComplete = true;
+ });
+ }
+ }
+
+ public interface Condition {
+ boolean test();
+ }
+
+ public static void loopUntilIdle(final long timeout) {
+ AtomicBoolean idle = new AtomicBoolean(false);
+
+ MessageQueue.IdleHandler handler = null;
+ try {
+ handler = () -> {
+ idle.set(true);
+ // Remove handler
+ return false;
+ };
+
+ HANDLER.getLooper().getQueue().addIdleHandler(handler);
+
+ waitForCondition(() -> idle.get(), timeout);
+ } finally {
+ if (handler != null) {
+ HANDLER.getLooper().getQueue().removeIdleHandler(handler);
+ }
+ }
+ }
+
+ public static void waitForCondition(Condition condition, final long timeout) {
+ // Adapted from GeckoThread.pumpMessageLoop.
+ final MessageQueue queue = HANDLER.getLooper().getQueue();
+
+ TIMEOUT_RUNNABLE.set(timeout);
+
+ MessageQueue.IdleHandler handler = null;
+ try {
+ handler = () -> {
+ HANDLER.postDelayed(() -> {}, 100);
+ return true;
+ };
+
+ HANDLER.getLooper().getQueue().addIdleHandler(handler);
+ while (!condition.test()) {
+ final Message msg;
+ try {
+ msg = (Message) sGetNextMessage.invoke(queue);
+ } catch (final IllegalAccessException | InvocationTargetException e) {
+ throw unwrapRuntimeException(e);
+ }
+ if (msg.getTarget() == null) {
+ HANDLER.getLooper().quit();
+ return;
+ }
+ msg.getTarget().dispatchMessage(msg);
+ }
+ } finally {
+ TIMEOUT_RUNNABLE.cancel();
+ if (handler != null) {
+ HANDLER.getLooper().getQueue().removeIdleHandler(handler);
+ }
+ }
+ }
+}