diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /mobile/android/geckoview/src/androidTest/java | |
parent | Initial commit. (diff) | |
download | firefox-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')
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> + * @Setting.List(@Setting(key = Setting.Key.USE_TRACKING_PROTECTION, + * value = "false")) + * @Test public void test() { ... } + * </pre> + * <p> + * Use multiple settings: + * <pre> + * @Setting.List({@Setting(key = Setting.Key.USE_PRIVATE_MODE, + * value = "true"), + * @Setting(key = Setting.Key.USE_TRACKING_PROTECTION, + * value = "false")}) + * </pre> + */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Setting { + enum Key { + CHROME_URI, + DISPLAY_MODE, + ALLOW_JAVASCRIPT, + SCREEN_ID, + USE_PRIVATE_MODE, + USE_TRACKING_PROTECTION, + FULL_ACCESSIBILITY_TREE; + + private final GeckoSessionSettings.Key<?> mKey; + private final Class<?> mType; + + Key() { + final Field field; + try { + field = GeckoSessionSettings.class.getDeclaredField(name()); + field.setAccessible(true); + mKey = (GeckoSessionSettings.Key<?>) field.get(null); + } catch (final NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + + final ParameterizedType genericType = (ParameterizedType) field.getGenericType(); + mType = (Class<?>) genericType.getActualTypeArguments()[0]; + } + + @SuppressWarnings("unchecked") + public void set(final GeckoSessionSettings settings, final String value) { + try { + if (boolean.class.equals(mType) || Boolean.class.equals(mType)) { + 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); + } + } + } +} |