summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/androidTest/java/org
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/androidTest/java/org')
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java167
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt2275
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt2532
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt715
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt297
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt302
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt51
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt278
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt161
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt660
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt23
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt727
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt878
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt456
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt120
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java673
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt37
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt2114
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt462
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java21
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt294
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt63
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt303
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt306
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt417
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt43
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt177
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt197
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt1031
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java213
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt3126
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt35
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt145
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt311
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt613
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt159
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt30
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt1132
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt255
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt105
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt52
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt45
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt582
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt1084
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt253
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt433
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt913
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt240
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt874
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt131
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java35
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java103
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java281
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java404
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt1407
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java119
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt88
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt545
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt2989
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt386
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt257
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java165
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt48
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt19
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java2915
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java11
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java93
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java175
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt167
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java167
70 files changed, 36885 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java
new file mode 100644
index 0000000000..98d43238a7
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java
@@ -0,0 +1,167 @@
+package org.mozilla.geckoview;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.geckoview.test.BaseSessionTest;
+
+@RunWith(AndroidJUnit4.class)
+@MediumTest
+public class GeckoInputStreamTest extends BaseSessionTest {
+
+ @Test
+ public void readAndWriteFile() throws IOException, ExecutionException, InterruptedException {
+ final byte[] originalBytes = getTestBytes(TEST_GIF_PATH);
+ final File createdFile = File.createTempFile("temp", ".gif");
+ final GeckoInputStream geckoInputStream = new GeckoInputStream(null);
+
+ // Reads from the GeckoInputStream and rewrites to a new file
+ final Thread readAndRewrite =
+ new Thread() {
+ public void run() {
+ try (OutputStream output = new FileOutputStream(createdFile)) {
+ byte[] buffer = new byte[4 * 1024];
+ int read;
+ while ((read = geckoInputStream.read(buffer)) != -1) {
+ output.write(buffer, 0, read);
+ }
+ output.flush();
+ geckoInputStream.close();
+ } catch (IOException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+ };
+
+ // Writes the bytes from the original file to the GeckoInputStream
+ final Thread write =
+ new Thread() {
+ public void run() {
+ try {
+ geckoInputStream.appendBuffer(originalBytes);
+ } catch (IOException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ geckoInputStream.sendEof();
+ }
+ };
+
+ final CompletableFuture<Void> testReadWrite =
+ CompletableFuture.allOf(
+ CompletableFuture.runAsync(readAndRewrite), CompletableFuture.runAsync(write));
+ testReadWrite.get();
+
+ final byte[] fileContent = new byte[(int) createdFile.length()];
+ final FileInputStream fis = new FileInputStream(createdFile);
+ fis.read(fileContent);
+ fis.close();
+
+ Assert.assertTrue("File was recreated correctly.", Arrays.equals(originalBytes, fileContent));
+ }
+
+ class Writer implements Runnable {
+ final char threadName;
+ final int timesToRun;
+ final GeckoInputStream stream;
+
+ public Writer(char threadName, int timesToRun, GeckoInputStream stream) {
+ this.threadName = threadName;
+ this.timesToRun = timesToRun;
+ this.stream = stream;
+ }
+
+ public void run() {
+ for (int i = 0; i <= timesToRun; i++) {
+ final byte[] data = String.format("%s %d %n", threadName, i).getBytes();
+ try {
+ stream.appendBuffer(data);
+ } catch (IOException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+ }
+ }
+
+ private boolean isSequenceInOrder(
+ List<String> lines, List<Character> threadNames, int dataLength) {
+ HashMap<Character, Integer> lastValue = new HashMap<>();
+ for (Character thread : threadNames) {
+ lastValue.put(thread, -1);
+ }
+ for (String line : lines) {
+ final char thread = line.charAt(0);
+ final int number = Integer.parseInt(line.replaceAll("[\\D]", ""));
+
+ // Number should always be in sequence for a given thread
+ if (lastValue.get(thread) + 1 == number) {
+ lastValue.replace(thread, number);
+ } else {
+ return false;
+ }
+ }
+ for (Character thread : threadNames) {
+ if (lastValue.get(thread) != dataLength) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Test
+ public void multipleWriters() throws ExecutionException, InterruptedException, IOException {
+ final GeckoInputStream geckoInputStream = new GeckoInputStream(null);
+ final List<Character> threadNames = Arrays.asList('A', 'B');
+ final int writeCount = 1000;
+ final CompletableFuture<Void> writers =
+ CompletableFuture.allOf(
+ CompletableFuture.runAsync(
+ new Writer(threadNames.get(0), writeCount, geckoInputStream)),
+ CompletableFuture.runAsync(
+ new Writer(threadNames.get(1), writeCount, geckoInputStream)));
+ writers.get();
+ geckoInputStream.sendEof();
+
+ final List<String> lines = new ArrayList<>();
+ final BufferedReader reader = new BufferedReader(new InputStreamReader(geckoInputStream));
+ while (reader.ready()) {
+ lines.add(reader.readLine());
+ }
+ reader.close();
+
+ Assert.assertTrue(
+ "Writers wrote as expected.", isSequenceInOrder(lines, threadNames, writeCount));
+ }
+
+ @Test
+ public void writeError() throws IOException {
+ boolean didThrowIoException = false;
+ final GeckoInputStream inputStream = new GeckoInputStream(null);
+ final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
+ final byte[] data = "Hello, World.".getBytes();
+ inputStream.appendBuffer(data);
+ inputStream.writeError();
+ inputStream.sendEof();
+ try {
+ reader.readLine();
+ } catch (IOException e) {
+ didThrowIoException = true;
+ }
+ reader.close();
+ Assert.assertTrue("Correctly caused an IOException from writer.", didThrowIoException);
+ }
+}
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..0f1fa260cb
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt
@@ -0,0 +1,2275 @@
+/* -*- 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.Rect
+import android.os.Build
+import android.os.Bundle
+import android.os.SystemClock
+import android.text.InputType
+import android.util.SparseLongArray
+import android.view.View
+import android.view.ViewGroup
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityNodeProvider
+import android.view.accessibility.AccessibilityRecord
+import android.widget.EditText
+import android.widget.FrameLayout
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.After
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+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.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ShouldContinue
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+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>()
+ private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+
+ @get:Rule
+ override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ // 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 {
+ try {
+ val field = AccessibilityNodeInfo::class.java.getDeclaredField("mChildNodeIds")
+ field.setAccessible(true)
+ val id = Class.forName("android.util.LongArray").getMethod("get", Int::class.java).invoke(field.get(this), index) as Long
+ return getVirtualDescendantId(id)
+ } catch (ex: Exception) {
+ return 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.runtime.settings.forceEnableAccessibility = true
+ 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.runtime.settings.forceEnableAccessibility = false
+ mainSession.accessibility.view = null
+ if (Build.VERSION.SDK_INT < 33) {
+ nodeInfos.forEach { node ->
+ @Suppress("DEPRECATION")
+ 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() {
+ mainSession.loadTestPath(INPUTS_PATH)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) { }
+ })
+ }
+
+ @Test fun testAccessibilityFocusAboutMozilla() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ mainSession.loadUri("about:license")
+
+ sessionRule.waitUntilCalled(object : GeckoSession.NavigationDelegate {
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: GeckoSession.NavigationDelegate.LoadRequest,
+ ): GeckoResult<AllowOrDeny>? {
+ return GeckoResult.allow()
+ }
+ })
+
+ // XXX: Local pages do not dispatch focus events when loaded
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled
+ override fun onWinStateChanged(event: AccessibilityEvent) { }
+
+ @AssertCalled
+ override fun onWinContentChanged(event: AccessibilityEvent) { }
+ })
+
+ provider.performAction(
+ View.NO_ID,
+ 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(
+ "Header is a11y focused",
+ node.contentDescription.toString(),
+ equalTo("Licenses"),
+ )
+ }
+ })
+
+ 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(
+ "Next text leaf is focused",
+ node.text.toString(),
+ equalTo("All of the "),
+ )
+ }
+ })
+
+ 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,
+ equalTo("free"),
+ )
+ }
+ })
+ }
+
+ @Test fun testAccessibilityFocus() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ mainSession.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) {
+ mainSession.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"))
+ }
+ })
+
+ // This focuses the link.
+ 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);
+ // Changing DOM selection doesn't focus the document! Force focus
+ // here so we can use that to determine when this is done.
+ document.activeElement.blur();
+ """.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, text: String) {
+ var eventFromIndex = -1
+ var eventToIndex = -1
+ var eventText = ""
+ do {
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ override fun onTextSelectionChanged(event: AccessibilityEvent) {
+ eventFromIndex = event.fromIndex
+ eventToIndex = event.toIndex
+ eventText = event.text[0].toString()
+ }
+ })
+ } while (fromIndex != eventFromIndex || toIndex != eventToIndex)
+ assertThat("text selection event text matches", eventText, equalTo(text))
+ }
+
+ 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() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Writing clipboard requires foreground on Android 10.
+ activityRule.scenario?.onActivity { activity ->
+ activity.onWindowFocusChanged(true)
+ }
+ }
+
+ 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, "hello cruel world")
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_COPY, null)
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(11, 11))
+ waitUntilTextSelectionChanged(11, 11, "hello cruel world")
+
+ 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"))
+ assertThat("fromIndex is correct", event.fromIndex, equalTo(12))
+ assertThat("addedCount is correct", event.addedCount, equalTo(6))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(17, 23))
+ waitUntilTextSelectionChanged(17, 23, "hello cruel cruel world")
+
+ 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"))
+ assertThat("fromIndex is correct", event.fromIndex, equalTo(18))
+ assertThat("removedCount is correct", event.removedCount, equalTo(5))
+ }
+ })
+
+ provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(0, 0))
+ waitUntilTextSelectionChanged(0, 0, "hello cruel cruel cruel")
+
+ provider.performAction(
+ nodeId,
+ AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+ moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD, true),
+ )
+ waitUntilTextSelectionChanged(0, 5, "hello cruel cruel cruel")
+
+ 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"))
+ assertThat("fromIndex is correct", event.fromIndex, equalTo(0))
+ assertThat("removedCount is correct", event.removedCount, equalTo(5))
+ }
+ })
+ }
+
+ @Test fun testMoveByCharacter() {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ mainSession.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
+ mainSession.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
+ mainSession.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
+ mainSession.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
+ mainSession.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
+ mainSession.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)
+
+ // Ensure that querying an option outside of a selectable container
+ // doesn't crash (bug 1801879).
+ mainSession.evaluateJS("document.getElementById('outsideSelectable').focus()")
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Focused outsideSelectable", node.text.toString(), equalTo("outside selectable"))
+ }
+ })
+ }
+
+ @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))
+ }
+ })
+ }
+
+ @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 $doc.defaultView.InputEvent ? "InputEvent" :
+ event instanceof $doc.defaultView.UIEvent ? "UIEvent" :
+ event instanceof $doc.defaultView.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"))
+ }
+ }
+
+ @Test
+ fun autoFill_navigation() {
+ // Fails with BFCache in the parent.
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1715480
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "fission.bfcacheInParent" to 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).sumOf {
+ countAutoFillNodes(cond, info.getChildId(it))
+ }
+ } else {
+ 0
+ }
+ )
+ }
+
+ // XXX: Reliably waiting for iframes to load could be flaky, so we wait
+ // for our autofill nodes to be the right number.
+ fun waitForAutoFillNodes() {
+ val checkAutoFillNodes = object : EventDelegate, ShouldContinue {
+ var haveAllAutoFills = countAutoFillNodes() == 18
+
+ override fun shouldContinue(): Boolean = !haveAllAutoFills
+
+ override fun onWinContentChanged(event: AccessibilityEvent) {
+ haveAllAutoFills = countAutoFillNodes() == 18
+ }
+ }
+ if (checkAutoFillNodes.shouldContinue()) {
+ sessionRule.waitUntilCalled(checkAutoFillNodes)
+ }
+ }
+
+ // Wait for the accessibility nodes to populate.
+ mainSession.loadTestPath(FORMS_HTML_PATH)
+ waitForInitialFocus()
+ waitForAutoFillNodes()
+
+ assertThat(
+ "Initial auto-fill count should match",
+ countAutoFillNodes(),
+ equalTo(18),
+ )
+ 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()
+ waitForAutoFillNodes()
+ assertThat(
+ "Should have auto-fill fields again",
+ countAutoFillNodes(),
+ equalTo(18),
+ )
+ 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),
+ )
+ }
+
+ @Test
+ fun testTree() {
+ loadTestPage("test-tree")
+ waitForInitialFocus()
+
+ val rootNode = createNodeInfo(View.NO_ID)
+ assertThat("Document has 3 children", rootNode.childCount, equalTo(3))
+ var rootBounds = Rect()
+ rootNode.getBoundsInScreen(rootBounds)
+ assertThat("Root node bounds are not empty", rootBounds.isEmpty, equalTo(false))
+ assertThat("Root node is visible to user", rootNode.isVisibleToUser, equalTo(true))
+
+ var labelBounds = Rect()
+ val labelNode = createNodeInfo(rootNode.getChildId(0))
+ labelNode.getBoundsInScreen(labelBounds)
+
+ assertThat("Label bounds are in parent", rootBounds.contains(labelBounds), equalTo(true))
+ assertThat("First node is a label", labelNode.className.toString(), equalTo("android.view.View"))
+ assertThat("Label has text", labelNode.text.toString(), equalTo("Name:"))
+ assertThat("Label node is visible to user", labelNode.isVisibleToUser, equalTo(true))
+
+ 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"))
+ assertThat("Entry node is visible to user", entryNode.isVisibleToUser, equalTo(true))
+ 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"))
+ assertThat("Button is visible to user", buttonNode.isVisibleToUser, equalTo(true))
+ }
+
+ @Test fun testLoadUnloadIframeDoc() {
+ mainSession.loadTestPath(REMOTE_IFRAME)
+ waitForInitialFocus()
+
+ loadTestPage("test-tree")
+ waitForInitialFocus()
+
+ mainSession.loadTestPath(REMOTE_IFRAME)
+ waitForInitialFocus()
+
+ loadTestPage("test-tree")
+ waitForInitialFocus()
+
+ mainSession.loadTestPath(REMOTE_IFRAME)
+ waitForInitialFocus()
+
+ loadTestPage("test-tree")
+ waitForInitialFocus()
+ }
+
+ private fun testAccessibilityFocusIframe(page: String) {
+ var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
+ mainSession.loadTestPath(page)
+ waitForInitialFocus(true)
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Label has text", node.text.toString(), equalTo("Some stuff "))
+ }
+ })
+
+ 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("heading has correct content", node.text as String, equalTo("Hello, world!"))
+ }
+ })
+
+ provider.performAction(
+ nodeId,
+ AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT,
+ null,
+ )
+
+ sessionRule.waitUntilCalled(object : EventDelegate {
+ @AssertCalled(count = 1)
+ override fun onAccessibilityFocused(event: AccessibilityEvent) {
+ nodeId = getSourceId(event)
+ val node = createNodeInfo(nodeId)
+ assertThat("Label has text", node.text.toString(), equalTo("Some stuff "))
+ }
+ })
+ }
+
+ @Test fun testRemoteAccessibilityFocusIframe() {
+ testAccessibilityFocusIframe(REMOTE_IFRAME)
+ }
+
+ @Test fun testLocalAccessibilityFocusIframe() {
+ testAccessibilityFocusIframe(LOCAL_IFRAME)
+ }
+
+ private fun testIframeTree(page: String) {
+ mainSession.loadTestPath(page)
+ waitForInitialFocus()
+
+ val rootNode = createNodeInfo(View.NO_ID)
+ assertThat("Document has 2 children", rootNode.childCount, equalTo(2))
+ var rootBounds = Rect()
+ rootNode.getBoundsInScreen(rootBounds)
+ assertThat("Root bounds are not empty", rootBounds.isEmpty, equalTo(false))
+
+ val labelNode = createNodeInfo(rootNode.getChildId(0))
+ assertThat("First node has text", labelNode.text.toString(), equalTo("Some stuff "))
+
+ val iframeNode = createNodeInfo(rootNode.getChildId(1))
+ assertThat("iframe has vieIdwResourceName of 'iframe'", iframeNode.viewIdResourceName, equalTo("iframe"))
+ assertThat("iframe has 1 child", iframeNode.childCount, equalTo(1))
+ var iframeBounds = Rect()
+ iframeNode.getBoundsInScreen(iframeBounds)
+ assertThat("iframe bounds in root bounds", rootBounds.contains(iframeBounds), equalTo(true))
+
+ val innerDocNode = createNodeInfo(iframeNode.getChildId(0))
+ assertThat("Inner doc has one child", innerDocNode.childCount, equalTo(1))
+ var innerDocBounds = Rect()
+ innerDocNode.getBoundsInScreen(innerDocBounds)
+ assertThat("iframe bounds match inner doc bounds", iframeBounds.contains(innerDocBounds), equalTo(true))
+
+ val section = createNodeInfo(innerDocNode.getChildId(0))
+ assertThat("section has one child", innerDocNode.childCount, equalTo(1))
+
+ val node = createNodeInfo(section.getChildId(0))
+ assertThat("Text node has text", node.text as String, equalTo("Hello, world!"))
+ var nodeBounds = Rect()
+ node.getBoundsInScreen(nodeBounds)
+ assertThat("inner node in inner doc bounds", innerDocBounds.contains(nodeBounds), equalTo(true))
+ }
+
+ @Test
+ fun testRemoteIframeTree() {
+ testIframeTree(REMOTE_IFRAME)
+ }
+
+ @Test
+ fun testLocalIframeTree() {
+ testIframeTree(LOCAL_IFRAME)
+ }
+
+ @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 correct rowIndex", firstListFirstItem.collectionItemInfo.rowIndex, equalTo(0))
+ }
+
+ 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))
+ }
+ }
+
+ @Test fun testNavigateListItems() {
+ loadTestPage("test-collection")
+ 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 text leaf",
+ node.text as String,
+ startsWith("One"),
+ )
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat(
+ "first item is a text leaf",
+ node.extras.getCharSequence("AccessibilityNodeInfo.geckoRole")!!.toString(),
+ equalTo("text leaf"),
+ )
+ }
+ }
+ })
+
+ 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 link",
+ node.contentDescription as String,
+ startsWith("Two"),
+ )
+ if (Build.VERSION.SDK_INT >= 19) {
+ assertThat(
+ "second item is a link",
+ node.extras.getCharSequence("AccessibilityNodeInfo.geckoRole")!!.toString(),
+ equalTo("link"),
+ )
+ }
+ }
+ })
+ }
+
+ @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..fbfe2fe46d
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt
@@ -0,0 +1,2532 @@
+/* -*- 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 android.view.KeyEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.Autocomplete.Address
+import org.mozilla.geckoview.Autocomplete.AddressSelectOption
+import org.mozilla.geckoview.Autocomplete.CreditCard
+import org.mozilla.geckoview.Autocomplete.CreditCardSaveOption
+import org.mozilla.geckoview.Autocomplete.CreditCardSelectOption
+import org.mozilla.geckoview.Autocomplete.LoginEntry
+import org.mozilla.geckoview.Autocomplete.LoginSaveOption
+import org.mozilla.geckoview.Autocomplete.LoginSelectOption
+import org.mozilla.geckoview.Autocomplete.SelectOption
+import org.mozilla.geckoview.Autocomplete.StorageDelegate
+import org.mozilla.geckoview.Autocomplete.UsedField
+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.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class AutocompleteTest : BaseSessionTest() {
+ val acceptDelay: Long = 100
+
+ // This is a utility to delete previous credit card and address information.
+ // Some credit card tests may not use fetched data since pop up is opened
+ // before fetching it.
+ private fun clearData() {
+ mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val fetchHandled = GeckoResult<Void>()
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ return null
+ }
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ Handler(Looper.getMainLooper()).postDelayed({
+ fetchHandled.complete(null)
+ }, acceptDelay)
+
+ return null
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ sessionRule.waitForResult(fetchHandled)
+ }
+
+ @Test
+ fun loginBuilderDefaultValue() {
+ val login = LoginEntry.Builder()
+ .build()
+
+ assertThat(
+ "Guid should match",
+ login.guid,
+ equalTo(null),
+ )
+ assertThat(
+ "Origin should match",
+ login.origin,
+ equalTo(""),
+ )
+ assertThat(
+ "Form action origin should match",
+ login.formActionOrigin,
+ equalTo(null),
+ )
+ assertThat(
+ "HTTP realm should match",
+ login.httpRealm,
+ equalTo(null),
+ )
+ assertThat(
+ "Username should match",
+ login.username,
+ equalTo(""),
+ )
+ assertThat(
+ "Password should match",
+ login.password,
+ equalTo(""),
+ )
+ }
+
+ @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 fetchHandled = GeckoResult<Void>()
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ fetchHandled.complete(null)
+ }, acceptDelay)
+
+ return null
+ }
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ sessionRule.waitForResult(fetchHandled)
+ }
+
+ @Test
+ fun fetchCreditCards() {
+ val fetchHandled = GeckoResult<Void>()
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ Handler(Looper.getMainLooper()).postDelayed({
+ fetchHandled.complete(null)
+ }, acceptDelay)
+
+ return null
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ sessionRule.waitForResult(fetchHandled)
+ }
+
+ @Test
+ fun creditCardBuilderDefaultValue() {
+ val creditCard = CreditCard.Builder()
+ .build()
+
+ assertThat(
+ "Guid should match",
+ creditCard.guid,
+ equalTo(null),
+ )
+ assertThat(
+ "Name should match",
+ creditCard.name,
+ equalTo(""),
+ )
+ assertThat(
+ "Number should match",
+ creditCard.number,
+ equalTo(""),
+ )
+ assertThat(
+ "Expiration month should match",
+ creditCard.expirationMonth,
+ equalTo(""),
+ )
+ assertThat(
+ "Expiration year should match",
+ creditCard.expirationYear,
+ equalTo(""),
+ )
+ }
+
+ @Test
+ fun creditCardSelectAndFill() {
+ // Workaround to fetch and open prompt
+ clearData()
+
+ // Test:
+ // 1. Load a credit card form page.
+ // 2. Focus on the name input field.
+ // a. Ensure onCreditCardFetch is called.
+ // b. Return the saved entries.
+ // c. Ensure onCreditCardSelect is called.
+ // d. Select and return one of the options.
+ // e. Ensure the form is filled accordingly.
+
+ val name = arrayOf("Peter Parker", "John Doe")
+ val number = arrayOf("1234-1234-1234-1234", "2345-2345-2345-2345")
+ val guid = arrayOf("test-guid1", "test-guid2")
+ val expMonth = arrayOf("04", "08")
+ val expYear = arrayOf("22", "23")
+ val savedCC = arrayOf(
+ CreditCard.Builder()
+ .guid(guid[0])
+ .name(name[0])
+ .number(number[0])
+ .expirationMonth(expMonth[0])
+ .expirationYear(expYear[0])
+ .build(),
+ CreditCard.Builder()
+ .guid(guid[1])
+ .name(name[1])
+ .number(number[1])
+ .expirationMonth(expMonth[1])
+ .expirationYear(expYear[1])
+ .build(),
+ )
+
+ val selectHandled = GeckoResult<Void>()
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ return GeckoResult.fromValue(savedCC)
+ }
+
+ @AssertCalled(false)
+ override fun onCreditCardSave(creditCard: CreditCard) {}
+ })
+
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onCreditCardSelect(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<CreditCardSelectOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ assertThat(
+ "There should be two options",
+ prompt.options.size,
+ equalTo(2),
+ )
+
+ for (i in 0..1) {
+ val creditCard = prompt.options[i].value
+
+ assertThat("Credit card should not be null", creditCard, notNullValue())
+ assertThat(
+ "Name should match",
+ creditCard.name,
+ equalTo(name[i]),
+ )
+ assertThat(
+ "Number should match",
+ creditCard.number,
+ equalTo(number[i]),
+ )
+ assertThat(
+ "Expiration month should match",
+ creditCard.expirationMonth,
+ equalTo(expMonth[i]),
+ )
+ assertThat(
+ "Expiration year should match",
+ creditCard.expirationYear,
+ equalTo(expYear[i]),
+ )
+ }
+ Handler(Looper.getMainLooper()).postDelayed({
+ selectHandled.complete(null)
+ }, acceptDelay)
+
+ return GeckoResult.fromValue(prompt.confirm(prompt.options[0]))
+ }
+ })
+
+ // Focus on the name input field.
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ sessionRule.waitForResult(selectHandled)
+
+ assertThat(
+ "Filled name should match",
+ mainSession.evaluateJS("document.querySelector('#name').value") as String,
+ equalTo(name[0]),
+ )
+ assertThat(
+ "Filled number should match",
+ mainSession.evaluateJS("document.querySelector('#number').value") as String,
+ equalTo(number[0]),
+ )
+ assertThat(
+ "Filled expiration month should match",
+ mainSession.evaluateJS("document.querySelector('#expMonth').value") as String,
+ equalTo(expMonth[0]),
+ )
+ assertThat(
+ "Filled expiration year should match",
+ mainSession.evaluateJS("document.querySelector('#expYear').value") as String,
+ equalTo(expYear[0]),
+ )
+ }
+
+ @Test
+ fun addressBuilderDefaultValue() {
+ val address = Address.Builder()
+ .build()
+
+ assertThat(
+ "Guid should match",
+ address.guid,
+ equalTo(null),
+ )
+ assertThat(
+ "Name should match",
+ address.name,
+ equalTo(""),
+ )
+ assertThat(
+ "Given name should match",
+ address.givenName,
+ equalTo(""),
+ )
+ assertThat(
+ "Family name should match",
+ address.familyName,
+ equalTo(""),
+ )
+ assertThat(
+ "Street address should match",
+ address.streetAddress,
+ equalTo(""),
+ )
+ assertThat(
+ "Address level 1 should match",
+ address.addressLevel1,
+ equalTo(""),
+ )
+ assertThat(
+ "Address level 2 should match",
+ address.addressLevel2,
+ equalTo(""),
+ )
+ assertThat(
+ "Address level 3 should match",
+ address.addressLevel3,
+ equalTo(""),
+ )
+ assertThat(
+ "Postal code should match",
+ address.postalCode,
+ equalTo(""),
+ )
+ assertThat(
+ "Country should match",
+ address.country,
+ equalTo(""),
+ )
+ assertThat(
+ "Tel should match",
+ address.tel,
+ equalTo(""),
+ )
+ assertThat(
+ "Email should match",
+ address.email,
+ equalTo(""),
+ )
+ }
+
+ @Test
+ fun creditCardSelectDismiss() {
+ // Workaround to fetch and open prompt
+ clearData()
+
+ val name = arrayOf("Peter Parker", "John Doe", "Taro Yamada")
+ val number = arrayOf("1234-1234-1234-1234", "2345-2345-2345-2345", "5555-5555-5555-5555")
+ val guid = arrayOf("test-guid1", "test-guid2", "test-guid3")
+ val expMonth = arrayOf("04", "08", "12")
+ val expYear = arrayOf("22", "23", "24")
+ val savedCC = arrayOf(
+ CreditCard.Builder()
+ .guid(guid[0])
+ .name(name[0])
+ .number(number[0])
+ .expirationMonth(expMonth[0])
+ .expirationYear(expYear[0])
+ .build(),
+ CreditCard.Builder()
+ .guid(guid[1])
+ .name(name[1])
+ .number(number[1])
+ .expirationMonth(expMonth[1])
+ .expirationYear(expYear[1])
+ .build(),
+ CreditCard.Builder()
+ .guid(guid[2])
+ .name(name[2])
+ .number(number[2])
+ .expirationMonth(expMonth[2])
+ .expirationYear(expYear[2])
+ .build(),
+ )
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ return GeckoResult.fromValue(savedCC)
+ }
+ })
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ val promptHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled
+ override fun onCreditCardSelect(session: GeckoSession, prompt: AutocompleteRequest<CreditCardSelectOption>): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat(
+ "There should be three options",
+ prompt.options.size,
+ equalTo(3),
+ )
+ prompt.setDelegate(promptInstanceDelegate)
+ Handler(Looper.getMainLooper()).postDelayed({
+ promptHandled.complete(null)
+ }, acceptDelay)
+
+ return GeckoResult()
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ sessionRule.waitForResult(promptHandled)
+ mainSession.evaluateJS("document.querySelector('#name').blur()")
+ sessionRule.waitForResult(result)
+ }
+
+ @Test
+ fun fetchAddresses() {
+ val fetchHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ Handler(Looper.getMainLooper()).postDelayed({
+ fetchHandled.complete(null)
+ }, acceptDelay)
+
+ return null
+ }
+ })
+
+ mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ sessionRule.waitForResult(fetchHandled)
+ }
+
+ fun checkAddressesForCorrectness(savedAddresses: Array<Address>, selectedAddress: Address) {
+ // Test:
+ // 1. Load an address form page.
+ // 2. Focus on the given name input field.
+ // a. Ensure onAddressFetch is called.
+ // b. Return the saved entries.
+ // c. Ensure onAddressSelect is called.
+ // d. Select and return one of the options.
+ // e. Ensure the form is filled accordingly.
+
+ val selectHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ return GeckoResult.fromValue(savedAddresses)
+ }
+
+ @AssertCalled(false)
+ override fun onAddressSave(address: Address) {}
+ })
+
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onAddressSelect(
+ session: GeckoSession,
+ prompt: AutocompleteRequest<AddressSelectOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Session should not be null", session, notNullValue())
+
+ assertThat(
+ "There should be one option",
+ prompt.options.size,
+ equalTo(savedAddresses.size),
+ )
+
+ val addressOption = prompt.options.find { it.value.familyName == selectedAddress.familyName }
+ val address = addressOption?.value
+
+ assertThat("Address should not be null", address, notNullValue())
+ assertThat(
+ "Guid should match",
+ address?.guid,
+ equalTo(selectedAddress.guid),
+ )
+ assertThat(
+ "Name should match",
+ address?.name,
+ equalTo(selectedAddress.name),
+ )
+ assertThat(
+ "Given name should match",
+ address?.givenName,
+ equalTo(selectedAddress.givenName),
+ )
+ assertThat(
+ "Family name should match",
+ address?.familyName,
+ equalTo(selectedAddress.familyName),
+ )
+ assertThat(
+ "Street address should match",
+ address?.streetAddress,
+ equalTo(selectedAddress.streetAddress),
+ )
+ assertThat(
+ "Address level 1 should match",
+ address?.addressLevel1,
+ equalTo(selectedAddress.addressLevel1),
+ )
+ assertThat(
+ "Address level 2 should match",
+ address?.addressLevel2,
+ equalTo(selectedAddress.addressLevel2),
+ )
+ assertThat(
+ "Address level 3 should match",
+ address?.addressLevel3,
+ equalTo(selectedAddress.addressLevel3),
+ )
+ assertThat(
+ "Postal code should match",
+ address?.postalCode,
+ equalTo(selectedAddress.postalCode),
+ )
+ assertThat(
+ "Country should match",
+ address?.country,
+ equalTo(selectedAddress.country),
+ )
+ assertThat(
+ "Tel should match",
+ address?.tel,
+ equalTo(selectedAddress.tel),
+ )
+ assertThat(
+ "Email should match",
+ address?.email,
+ equalTo(selectedAddress.email),
+ )
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ selectHandled.complete(null)
+ }, acceptDelay)
+
+ return GeckoResult.fromValue(prompt.confirm(addressOption!!))
+ }
+ })
+
+ mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // Focus on the given name input field.
+ mainSession.evaluateJS("document.querySelector('#givenName').focus()")
+ sessionRule.waitForResult(selectHandled)
+
+ assertThat(
+ "Filled given name should match",
+ mainSession.evaluateJS("document.querySelector('#givenName').value") as String,
+ equalTo(selectedAddress.givenName),
+ )
+ assertThat(
+ "Filled family name should match",
+ mainSession.evaluateJS("document.querySelector('#familyName').value") as String,
+ equalTo(selectedAddress.familyName),
+ )
+ assertThat(
+ "Filled street address should match",
+ mainSession.evaluateJS("document.querySelector('#streetAddress').value") as String,
+ equalTo(selectedAddress.streetAddress),
+ )
+ assertThat(
+ "Filled country should match",
+ mainSession.evaluateJS("document.querySelector('#country').value") as String,
+ equalTo(selectedAddress.country),
+ )
+ assertThat(
+ "Filled postal code should match",
+ mainSession.evaluateJS("document.querySelector('#postalCode').value") as String,
+ equalTo(selectedAddress.postalCode),
+ )
+ assertThat(
+ "Filled email should match",
+ mainSession.evaluateJS("document.querySelector('#email').value") as String,
+ equalTo(selectedAddress.email),
+ )
+ assertThat(
+ "Filled telephone number should match",
+ mainSession.evaluateJS("document.querySelector('#tel').value") as String,
+ equalTo(selectedAddress.tel),
+ )
+ assertThat(
+ "Filled organization should match",
+ mainSession.evaluateJS("document.querySelector('#organization').value") as String,
+ equalTo(selectedAddress.organization),
+ )
+ }
+
+ @Test
+ fun addressSelectAndFill() {
+ val name = "Peter Parker"
+ val givenName = "Peter"
+ val familyName = "Parker"
+ val streetAddress = "20 Ingram Street, Forest Hills Gardens, Queens"
+ val postalCode = "11375"
+ val country = "US"
+ val email = "spiderman@newyork.com"
+ val tel = "+1 180090021"
+ val organization = ""
+ val guid = "test-guid"
+ val savedAddress = Address.Builder()
+ .guid(guid)
+ .name(name)
+ .givenName(givenName)
+ .familyName(familyName)
+ .streetAddress(streetAddress)
+ .postalCode(postalCode)
+ .country(country)
+ .email(email)
+ .tel(tel)
+ .organization(organization)
+ .build()
+ val savedAddresses = mutableListOf<Address>(savedAddress)
+
+ checkAddressesForCorrectness(savedAddresses.toTypedArray(), savedAddress)
+ }
+
+ @Test
+ fun addressSelectAndFillMultipleAddresses() {
+ val names = arrayOf("Peter Parker", "Wade Wilson")
+ val givenNames = arrayOf("Peter", "Wade")
+ val familyNames = arrayOf("Parker", "Wilson")
+ val streetAddresses = arrayOf("20 Ingram Street, Forest Hills Gardens, Queens", "890 Fifth Avenue, Manhattan")
+ val postalCodes = arrayOf("11375", "10110")
+ val countries = arrayOf("US", "US")
+ val emails = arrayOf("spiderman@newyork.com", "deadpool@newyork.com")
+ val tels = arrayOf("+1 180090021", "+1 180055555")
+ val organizations = arrayOf("", "")
+ val guids = arrayOf("test-guid-1", "test-guid-2")
+ val selectedAddress = Address.Builder()
+ .guid(guids[1])
+ .name(names[1])
+ .givenName(givenNames[1])
+ .familyName(familyNames[1])
+ .streetAddress(streetAddresses[1])
+ .postalCode(postalCodes[1])
+ .country(countries[1])
+ .email(emails[1])
+ .tel(tels[1])
+ .organization(organizations[1])
+ .build()
+ val savedAddresses = mutableListOf<Address>(
+ Address.Builder()
+ .guid(guids[0])
+ .name(names[0])
+ .givenName(givenNames[0])
+ .familyName(familyNames[0])
+ .streetAddress(streetAddresses[0])
+ .postalCode(postalCodes[0])
+ .country(countries[0])
+ .email(emails[0])
+ .tel(tels[0])
+ .organization(organizations[0])
+ .build(),
+ selectedAddress,
+ )
+
+ checkAddressesForCorrectness(savedAddresses.toTypedArray(), selectedAddress)
+ }
+
+ @Test
+ fun addressSelectDismiss() {
+ val name = "Peter Parker"
+ val givenName = "Peter"
+ val familyName = "Parker"
+ val streetAddress = "20 Ingram Street, Forest Hills Gardens, Queens"
+ val postalCode = "11375"
+ val country = "US"
+ val email = "spiderman@newyork.com"
+ val tel = "+1 180090021"
+ val organization = ""
+ val guid = "test-guid"
+ val savedAddress = Address.Builder()
+ .guid(guid)
+ .name(name)
+ .givenName(givenName)
+ .familyName(familyName)
+ .streetAddress(streetAddress)
+ .postalCode(postalCode)
+ .country(country)
+ .email(email)
+ .tel(tel)
+ .organization(organization)
+ .build()
+ val savedAddresses = mutableListOf<Address>(savedAddress)
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ return GeckoResult.fromValue(savedAddresses.toTypedArray())
+ }
+ })
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ val promptHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled
+ override fun onAddressSelect(session: GeckoSession, prompt: AutocompleteRequest<AddressSelectOption>): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat(
+ "There should be one option",
+ prompt.options.size,
+ equalTo(1),
+ )
+ prompt.setDelegate(promptInstanceDelegate)
+ Handler(Looper.getMainLooper()).postDelayed({
+ promptHandled.complete(null)
+ }, acceptDelay)
+
+ return GeckoResult()
+ }
+ })
+
+ mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.querySelector('#givenName').focus()")
+ sessionRule.waitForResult(promptHandled)
+ mainSession.evaluateJS("document.querySelector('#givenName').blur()")
+ sessionRule.waitForResult(result)
+ }
+
+ @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,
+ ),
+ )
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @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.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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,
+ ),
+ )
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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,
+ ),
+ )
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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 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.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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)
+ }
+
+ @Test
+ fun creditCardSaveAccept() {
+ val ccName = "MyCard"
+ val ccNumber = "5105105105105100"
+ val ccExpMonth = "6"
+ val ccExpYear = "2024"
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardSave(creditCard: CreditCard) {
+ assertThat("Credit card name should match", creditCard.name, equalTo(ccName))
+ assertThat("Credit card number should match", creditCard.number, equalTo(ccNumber))
+ assertThat("Credit card expiration month should match", creditCard.expirationMonth, equalTo(ccExpMonth))
+ assertThat("Credit card expiration year should match", creditCard.expirationYear, equalTo(ccExpYear))
+ saveHandled.complete(null)
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : PromptDelegate {
+ @AssertCalled
+ override fun onCreditCardSave(
+ session: GeckoSession,
+ request: AutocompleteRequest<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = request.options[0]
+ val cc = option.value
+
+ assertThat("Credit card should not be null", cc, notNullValue())
+
+ assertThat(
+ "Credit card name should match",
+ cc.name,
+ equalTo(ccName),
+ )
+ assertThat(
+ "Credit card number should match",
+ cc.number,
+ equalTo(ccNumber),
+ )
+ assertThat(
+ "Credit card expiration month should match",
+ cc.expirationMonth,
+ equalTo(ccExpMonth),
+ )
+ assertThat(
+ "Credit card expiration year should match",
+ cc.expirationYear,
+ equalTo(ccExpYear),
+ )
+
+ return GeckoResult.fromValue(request.confirm(option))
+ }
+ })
+
+ // Enter the card values
+ mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'")
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber'")
+ mainSession.evaluateJS("document.querySelector('#number').focus()")
+ mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth'")
+ mainSession.evaluateJS("document.querySelector('#expMonth').focus()")
+ mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear'")
+ mainSession.evaluateJS("document.querySelector('#expYear').focus()")
+
+ // Submit the form
+ mainSession.evaluateJS("document.querySelector('form').requestSubmit()")
+
+ sessionRule.waitForResult(saveHandled)
+ }
+
+ @Test
+ fun creditCardSaveAcceptForm2() {
+ // TODO Bug 1764709: Right now we fill normalized credit card data to match
+ // the expected result.
+ val ccName = "MyCard"
+ val ccNumber = "5105105105105100"
+ val ccExpMonth = "6"
+ val ccExpYear = "2024"
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardSave(creditCard: CreditCard) {
+ assertThat("Credit card name should match", creditCard.name, equalTo(ccName))
+ assertThat("Credit card number should match", creditCard.number, equalTo(ccNumber))
+ assertThat("Credit card expiration month should match", creditCard.expirationMonth, equalTo(ccExpMonth))
+ assertThat("Credit card expiration year should match", creditCard.expirationYear, equalTo(ccExpYear))
+ saveHandled.complete(null)
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : PromptDelegate {
+ @AssertCalled
+ override fun onCreditCardSave(
+ session: GeckoSession,
+ request: AutocompleteRequest<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = request.options[0]
+ val cc = option.value
+
+ assertThat("Credit card should not be null", cc, notNullValue())
+
+ assertThat(
+ "Credit card name should match",
+ cc.name,
+ equalTo(ccName),
+ )
+ assertThat(
+ "Credit card number should match",
+ cc.number,
+ equalTo(ccNumber),
+ )
+ assertThat(
+ "Credit card expiration month should match",
+ cc.expirationMonth,
+ equalTo(ccExpMonth),
+ )
+ assertThat(
+ "Credit card expiration year should match",
+ cc.expirationYear,
+ equalTo(ccExpYear),
+ )
+
+ return GeckoResult.fromValue(request.confirm(option))
+ }
+ })
+
+ // Enter the card values
+ mainSession.evaluateJS("document.querySelector('#form2 #name').value = '$ccName'")
+ mainSession.evaluateJS("document.querySelector('#form2 #name').focus()")
+ mainSession.evaluateJS("document.querySelector('#form2 #number').value = '$ccNumber'")
+ mainSession.evaluateJS("document.querySelector('#form2 #number').focus()")
+ mainSession.evaluateJS("document.querySelector('#form2 #exp').value = '$ccExpMonth/$ccExpYear'")
+ mainSession.evaluateJS("document.querySelector('#form2 #exp').focus()")
+
+ // Submit the form
+ mainSession.evaluateJS("document.querySelector('#form2').requestSubmit()")
+
+ sessionRule.waitForResult(saveHandled)
+ }
+
+ @Test
+ fun creditCardSaveDismiss() {
+ val ccName = "MyCard"
+ val ccNumber = "5105105105105100"
+ val ccExpMonth = "6"
+ val ccExpYear = "2024"
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ return null
+ }
+ })
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled(count = 0)
+ override fun onCreditCardSave(creditCard: CreditCard) {}
+ })
+
+ // Enter the card values
+ mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'")
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber'")
+ mainSession.evaluateJS("document.querySelector('#number').focus()")
+ mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth'")
+ mainSession.evaluateJS("document.querySelector('#expMonth').focus()")
+ mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear'")
+ mainSession.evaluateJS("document.querySelector('#expYear').focus()")
+
+ // Submit the form
+ mainSession.evaluateJS("document.querySelector('form').requestSubmit()")
+
+ sessionRule.waitUntilCalled(object : PromptDelegate {
+ @AssertCalled
+ override fun onCreditCardSave(
+ session: GeckoSession,
+ request: AutocompleteRequest<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = request.options[0]
+ val cc = option.value
+
+ assertThat("Credit card should not be null", cc, notNullValue())
+
+ assertThat(
+ "Credit card name should match",
+ cc.name,
+ equalTo(ccName),
+ )
+ assertThat(
+ "Credit card number should match",
+ cc.number,
+ equalTo(ccNumber),
+ )
+ assertThat(
+ "Credit card expiration month should match",
+ cc.expirationMonth,
+ equalTo(ccExpMonth),
+ )
+ assertThat(
+ "Credit card expiration year should match",
+ cc.expirationYear,
+ equalTo(ccExpYear),
+ )
+
+ return GeckoResult.fromValue(request.dismiss())
+ }
+ })
+ }
+
+ @Test
+ fun creditCardSaveModifyAccept() {
+ val ccName = "MyCard"
+ val ccNumber = "5105105105105100"
+ val ccExpMonth = "6"
+ val ccExpYearNew = "2026"
+ val ccExpYear = "2024"
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardSave(creditCard: CreditCard) {
+ assertThat("Credit card name should match", creditCard.name, equalTo(ccName))
+ assertThat("Credit card number should match", creditCard.number, equalTo(ccNumber))
+ assertThat("Credit card expiration month should match", creditCard.expirationMonth, equalTo(ccExpMonth))
+ assertThat("Credit card expiration year should match", creditCard.expirationYear, equalTo(ccExpYearNew))
+ saveHandled.complete(null)
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : PromptDelegate {
+ @AssertCalled
+ override fun onCreditCardSave(
+ session: GeckoSession,
+ request: AutocompleteRequest<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = request.options[0]
+ val cc = option.value
+
+ assertThat("Credit card should not be null", cc, notNullValue())
+
+ assertThat(
+ "Credit card name should match",
+ cc.name,
+ equalTo(ccName),
+ )
+ assertThat(
+ "Credit card number should match",
+ cc.number,
+ equalTo(ccNumber),
+ )
+ assertThat(
+ "Credit card expiration month should match",
+ cc.expirationMonth,
+ equalTo(ccExpMonth),
+ )
+ assertThat(
+ "Credit card expiration year should match",
+ cc.expirationYear,
+ equalTo(ccExpYear),
+ )
+
+ val modifiedCreditCard = CreditCard.Builder()
+ .name(cc.name)
+ .number(cc.number)
+ .expirationMonth(cc.expirationMonth)
+ .expirationYear(ccExpYearNew)
+ .build()
+
+ return GeckoResult.fromValue(request.confirm(CreditCardSaveOption(modifiedCreditCard)))
+ }
+ })
+
+ // Enter the card values
+ mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'")
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber'")
+ mainSession.evaluateJS("document.querySelector('#number').focus()")
+ mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth'")
+ mainSession.evaluateJS("document.querySelector('#expMonth').focus()")
+ mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear'")
+ mainSession.evaluateJS("document.querySelector('#expYear').focus()")
+
+ // Submit the form
+ mainSession.evaluateJS("document.querySelector('form').requestSubmit()")
+
+ sessionRule.waitForResult(saveHandled)
+ }
+
+ @Test
+ fun creditCardUpdateAccept() {
+ val ccName = "MyCard"
+ val ccNumber1 = "5105105105105100"
+ val ccExpMonth1 = "6"
+ val ccExpYear1 = "2024"
+ val ccNumber2 = "4111111111111111"
+ val ccExpMonth2 = "11"
+ val ccExpYear2 = "2021"
+ val savedCreditCards = mutableListOf<CreditCard>()
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled1 = GeckoResult<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>> {
+ return GeckoResult.fromValue(savedCreditCards.toTypedArray())
+ }
+
+ @AssertCalled(count = 2)
+ override fun onCreditCardSave(creditCard: CreditCard) {
+ assertThat(
+ "Credit card name should match",
+ creditCard.name,
+ equalTo(ccName),
+ )
+ assertThat(
+ "Credit card number should match",
+ creditCard.number,
+ equalTo(forEachCall(ccNumber1, ccNumber2)),
+ )
+ assertThat(
+ "Credit card expiration month should match",
+ creditCard.expirationMonth,
+ equalTo(forEachCall(ccExpMonth1, ccExpMonth2)),
+ )
+ assertThat(
+ "Credit card expiration year should match",
+ creditCard.expirationYear,
+ equalTo(forEachCall(ccExpYear1, ccExpYear2)),
+ )
+
+ val savedCC = CreditCard.Builder()
+ .guid("test1")
+ .name(creditCard.name)
+ .number(creditCard.number)
+ .expirationMonth(creditCard.expirationMonth)
+ .expirationYear(creditCard.expirationYear)
+ .build()
+ savedCreditCards.add(savedCC)
+
+ if (sessionRule.currentCall.counter == 1) {
+ saveHandled1.complete(null)
+ } else if (sessionRule.currentCall.counter == 2) {
+ saveHandled2.complete(null)
+ }
+ }
+ })
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onCreditCardSave(
+ session: GeckoSession,
+ request: AutocompleteRequest<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Session should not be null", session, notNullValue())
+
+ val option = request.options[0]
+ val cc = option.value
+
+ assertThat("Credit card should not be null", cc, notNullValue())
+
+ assertThat(
+ "Credit card name should match",
+ cc.name,
+ equalTo(ccName),
+ )
+ assertThat(
+ "Credit card number should match",
+ cc.number,
+ equalTo(forEachCall(ccNumber1, ccNumber2)),
+ )
+ assertThat(
+ "Credit card expiration month should match",
+ cc.expirationMonth,
+ equalTo(forEachCall(ccExpMonth1, ccExpMonth2)),
+ )
+ assertThat(
+ "Credit card expiration year should match",
+ cc.expirationYear,
+ equalTo(forEachCall(ccExpYear1, ccExpYear2)),
+ )
+
+ return GeckoResult.fromValue(request.confirm(option))
+ }
+ })
+
+ // Enter the card values
+ mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'")
+ mainSession.evaluateJS("document.querySelector('#name').focus()")
+ mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber1'")
+ mainSession.evaluateJS("document.querySelector('#number').focus()")
+ mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth1'")
+ mainSession.evaluateJS("document.querySelector('#expMonth').focus()")
+ mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear1'")
+ mainSession.evaluateJS("document.querySelector('#expYear').focus()")
+
+ // Submit the form
+ mainSession.evaluateJS("document.querySelector('form').requestSubmit()")
+
+ sessionRule.waitForResult(saveHandled1)
+
+ // Update credit card
+ val session2 = sessionRule.createOpenSession()
+ session2.loadTestPath(CC_FORM_HTML_PATH)
+ session2.waitForPageStop()
+ session2.evaluateJS("document.querySelector('#name').value = '$ccName'")
+ session2.evaluateJS("document.querySelector('#name').focus()")
+ session2.evaluateJS("document.querySelector('#number').value = '$ccNumber2'")
+ session2.evaluateJS("document.querySelector('#number').focus()")
+ session2.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth2'")
+ session2.evaluateJS("document.querySelector('#expMonth').focus()")
+ session2.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear2'")
+ session2.evaluateJS("document.querySelector('#expYear').focus()")
+
+ session2.evaluateJS("document.querySelector('form').requestSubmit()")
+
+ 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 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.delegateUntilTestEnd(object : StorageDelegate {
+ @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(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.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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 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.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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 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.delegateUntilTestEnd(object : StorageDelegate {
+ @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(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 : 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 : 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 : 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(Looper.getMainLooper()).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 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.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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 : 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 : 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(Looper.getMainLooper()).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 user1 = "user1x"
+ var genPass = ""
+
+ val saveHandled1 = GeckoResult<Void>()
+ val selectHandled = GeckoResult<Void>()
+ var numSelects = 0
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @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 : 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(SelectOption.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(Looper.getMainLooper()).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()
+ }
+
+ @Test
+ fun loginSelectDismiss() {
+ 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 user = arrayOf("user1x", "user2x")
+ val pass = arrayOf("pass1x", "pass2x")
+ val guid = arrayOf("test-guid1", "test-guid2")
+ val origin = GeckoSessionTestRule.TEST_ENDPOINT
+ val savedLogins = arrayOf(
+ LoginEntry.Builder()
+ .guid(guid[0])
+ .origin(origin)
+ .formActionOrigin(origin)
+ .username(user[0])
+ .password(pass[0])
+ .build(),
+ LoginEntry.Builder()
+ .guid(guid[1])
+ .origin(origin)
+ .formActionOrigin(origin)
+ .username(user[1])
+ .password(pass[1])
+ .build(),
+ )
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ return GeckoResult.fromValue(savedLogins)
+ }
+ })
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ val promptHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled
+ override fun onLoginSelect(session: GeckoSession, prompt: AutocompleteRequest<LoginSelectOption>): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat(
+ "There should be two options",
+ prompt.options.size,
+ equalTo(2),
+ )
+ prompt.setDelegate(promptInstanceDelegate)
+ Handler(Looper.getMainLooper()).postDelayed({
+ promptHandled.complete(null)
+ }, acceptDelay)
+
+ return GeckoResult()
+ }
+ })
+
+ mainSession.loadTestPath(FORMS3_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.querySelector('#user1').focus()")
+ sessionRule.waitForResult(promptHandled)
+ mainSession.evaluateJS("document.querySelector('#user1').blur()")
+ sessionRule.waitForResult(result)
+ }
+}
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..f1adc7bf1e
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt
@@ -0,0 +1,715 @@
+/* -*- 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.Rect
+import android.util.SparseArray
+import android.view.KeyEvent
+import android.view.View
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mozilla.geckoview.Autofill
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.TextInputDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports
+
+@RunWith(Parameterized::class)
+@MediumTest
+class AutofillDelegateTest : BaseSessionTest() {
+
+ companion object {
+ @get:Parameterized.Parameters(name = "{0}")
+ @JvmStatic
+ val parameters: List<Array<out Any>> = listOf(
+ arrayOf("#inProcess"),
+ arrayOf("#oop"),
+ )
+ }
+
+ @field:Parameterized.Parameter(0)
+ @JvmField
+ var iframe: String = ""
+
+ // Whether the iframe is loaded in-process (i.e. with the same origin as the
+ // outer html page) or out-of-process.
+ private val pageUrl by lazy {
+ when (iframe) {
+ "#inProcess" -> "http://example.org/tests/junit/forms_xorigin.html"
+ "#oop" -> createTestUrl(FORMS_XORIGIN_HTML_PATH)
+ else -> throw IllegalStateException()
+ }
+ }
+
+ @Test fun autofillCommit() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "signon.rememberSignons" to true,
+ "signon.userInputRequiredToCapture.enabled" to false,
+ ),
+ )
+
+ mainSession.loadUri(pageUrl)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ // We expect to get a call to onSessionStart and many calls to onNodeAdd depending
+ // on timing.
+ @AssertCalled(count = 1)
+ override fun onSessionStart(session: GeckoSession) {}
+
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ // 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 : Autofill.Delegate {
+ @AssertCalled(order = [1, 2, 3, 4])
+ override fun onNodeUpdate(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ }
+
+ @AssertCalled(order = [5])
+ override fun onSessionCommit(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ val autofillSession = mainSession.autofillSession
+ assertThat(
+ "Values should match",
+ countAutofillNodes({
+ autofillSession.dataFor(it).value == "user1x"
+ }),
+ equalTo(1),
+ )
+ assertThat(
+ "Values should match",
+ countAutofillNodes({
+ autofillSession.dataFor(it).value == "pass1x"
+ }),
+ equalTo(1),
+ )
+ assertThat(
+ "Values should match",
+ countAutofillNodes({
+ autofillSession.dataFor(it).value == "e@mail.com"
+ }),
+ equalTo(1),
+ )
+ assertThat(
+ "Values should match",
+ countAutofillNodes({
+ autofillSession.dataFor(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 : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStart(session: GeckoSession) {}
+
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ // Assign node values.
+ mainSession.evaluateJS("document.querySelector('#value').value = 'pass1x'")
+
+ // Submit the session.
+ mainSession.evaluateJS("document.querySelector('#form1').submit()")
+
+ sessionRule.waitUntilCalled(object : Autofill.Delegate {
+ @AssertCalled(order = [1])
+ override fun onNodeUpdate(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ }
+
+ @AssertCalled(order = [2])
+ override fun onSessionCommit(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat(
+ "Values should match",
+ countAutofillNodes({
+ mainSession.autofillSession.dataFor(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.loadUri(pageUrl)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ // We expect many call to onNodeAdd while loading the page
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ 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.map { entry ->
+ // Repeat each test with both the top document and the iframe document.
+ mainSession.evaluatePromiseJS(
+ """
+ window.getDataForAllFrames('${entry.key}', '${entry.value}')
+ """,
+ )
+ }
+
+ val autofillValues = SparseArray<CharSequence>()
+
+ // Perform auto-fill and return number of auto-fills performed.
+ fun checkAutofillChild(child: Autofill.Node, domain: String) {
+ // Seal the node info instance so we can perform actions on it.
+ if (child.children.isNotEmpty()) {
+ for (c in child.children) {
+ checkAutofillChild(c!!, child.domain)
+ }
+ }
+
+ if (child == mainSession.autofillSession.root) {
+ return
+ }
+
+ assertThat(
+ "Should have HTML tag",
+ child.tag,
+ not(isEmptyOrNullString()),
+ )
+ if (domain != "") {
+ assertThat(
+ "Web domain should match its parent.",
+ child.domain,
+ equalTo(domain),
+ )
+ }
+
+ 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()))
+ }
+
+ val childId = mainSession.autofillSession.dataFor(child).id
+ autofillValues.append(
+ childId,
+ 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.autofillSession.autofill(autofillValues)
+
+ // Wait on the promises and check for correct values.
+ for (values in promises.map { it.value.asJsonArray() }) {
+ for (i in 0 until values.length()) {
+ val (key, actual, expected, eventInterface) = values.get(i).asJSList<String>()
+
+ assertThat("Auto-filled value must match ($key)", actual, equalTo(expected))
+ assertThat(
+ "input event should be dispatched with InputEvent interface",
+ eventInterface,
+ equalTo("InputEvent"),
+ )
+ }
+ }
+ }
+
+ @Test fun autofillUnknownValue() {
+ // Test parts of the Oreo auto-fill API; there is another autofill test in
+ // SessionAccessibility for a11y auto-fill support.
+ mainSession.loadUri(pageUrl)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ val autofillValues = SparseArray<CharSequence>()
+ autofillValues.append(-1, "lobster")
+ mainSession.autofillSession.autofill(autofillValues)
+ }
+
+ 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.sumOf {
+ countAutofillNodes(cond, it)
+ }
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun autofillNavigation() {
+ // Wait for the accessibility nodes to populate.
+ mainSession.loadUri(pageUrl)
+
+ sessionRule.waitUntilCalled(object :
+ Autofill.Delegate,
+ ShouldContinue,
+ GeckoSession.ProgressDelegate {
+ var nodeCount = 0
+
+ // Continue waiting util we get all 16 nodes
+ override fun shouldContinue(): Boolean = nodeCount < 16
+
+ @AssertCalled(count = 1)
+ override fun onSessionStart(session: GeckoSession) {}
+
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("Node should be valid", node, notNullValue())
+ nodeCount = countAutofillNodes()
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ assertThat(
+ "Initial auto-fill count should match",
+ countAutofillNodes(),
+ equalTo(16),
+ )
+
+ // Now wait for the nodes to clear.
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionCancel(session: GeckoSession) {}
+
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ assertThat(
+ "Should not have auto-fill fields",
+ countAutofillNodes(),
+ equalTo(0),
+ )
+
+ mainSession.goBack()
+ sessionRule.waitUntilCalled(object :
+ Autofill.Delegate,
+ GeckoSession.ProgressDelegate,
+ ShouldContinue {
+ var nodeCount = 0
+ override fun shouldContinue(): Boolean = nodeCount < 16
+
+ @AssertCalled(count = 1)
+ override fun onSessionStart(session: GeckoSession) {}
+
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("Node should be valid", node, notNullValue())
+ nodeCount = countAutofillNodes()
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ assertThat(
+ "Should have auto-fill fields again",
+ countAutofillNodes(),
+ equalTo(16),
+ )
+
+ var focused = mainSession.autofillSession.focused
+ assertThat(
+ "Should not have focused field",
+ countAutofillNodes({ it == focused }),
+ equalTo(0),
+ )
+
+ mainSession.evaluateJS("document.querySelector('#pass2').focus()")
+
+ sessionRule.waitUntilCalled(object : Autofill.Delegate {
+ @AssertCalled(count = 1)
+ override fun onNodeFocus(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+
+ focused = mainSession.autofillSession.focused
+ 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 nine visible nodes",
+ countAutofillNodes({ node -> mainSession.autofillSession.isVisible(node) }),
+ equalTo(8),
+ )
+
+ mainSession.evaluateJS("document.querySelector('#pass2').blur()")
+ sessionRule.waitUntilCalled(object : Autofill.Delegate {
+ @AssertCalled(count = 1)
+ override fun onNodeBlur(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+
+ focused = mainSession.autofillSession.focused
+ 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 : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStart(session: GeckoSession) {}
+
+ @AssertCalled(count = 1)
+ override fun onNodeFocus(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ // 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
+ }
+
+ val childId = mainSession.autofillSession.dataFor(child).id
+ assertThat("ID should be valid", childId, 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.loadUri(pageUrl)
+ // Wait for the auto-fill nodes to populate.
+ sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ // 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 = 1)
+ override fun onSessionStart(session: GeckoSession) {}
+
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ mainSession.evaluateJS("document.querySelector('#pass2').focus()")
+ sessionRule.waitUntilCalled(object : Autofill.Delegate {
+ @AssertCalled(count = 1)
+ override fun onNodeFocus(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+
+ var focused = mainSession.autofillSession.focused
+ 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 : Autofill.Delegate {
+ @AssertCalled(count = 1)
+ override fun onNodeBlur(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+
+ // Make sure we get NODE_FOCUSED when active once again
+ mainSession.setActive(true)
+ sessionRule.waitUntilCalled(object : Autofill.Delegate {
+ @AssertCalled(count = 1)
+ override fun onNodeFocus(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("ID should be valid", node, notNullValue())
+ }
+ })
+
+ focused = mainSession.autofillSession.focused
+ assertThat(
+ "Should have one focused field",
+ countAutofillNodes({ focused == it }),
+ equalTo(1),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun autofillAutocompleteAttribute() {
+ mainSession.loadTestPath(FORMS_AUTOCOMPLETE_HTML_PATH)
+ sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate {
+ @AssertCalled(count = -1)
+ override fun onNodeAdd(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {}
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+ })
+
+ 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),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun autofillWaitForKeyboard() {
+ // Wait for the accessibility nodes to populate.
+ mainSession.loadUri(pageUrl)
+ mainSession.waitForPageStop()
+
+ mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT)
+ mainSession.evaluateJS("document.querySelector('#pass2').focus()")
+
+ sessionRule.waitUntilCalled(object : Autofill.Delegate, TextInputDelegate {
+ @AssertCalled(order = [2])
+ override fun onNodeFocus(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("ID should be valid", node, notNullValue())
+ }
+
+ @AssertCalled(order = [1])
+ override fun showSoftInput(session: GeckoSession) {}
+ })
+ }
+
+ @WithDisplay(width = 300, height = 1000)
+ @Test
+ fun autofillIframe() {
+ // No way to click in x-origin frame.
+ assumeThat("Not in x-origin", iframe, not(equalTo("#oop")))
+
+ // Wait for the accessibility nodes to populate.
+ mainSession.loadUri(pageUrl)
+ mainSession.waitForPageStop()
+
+ // Get non-iframe position of input element
+ var screenRect = Rect()
+ mainSession.evaluateJS("document.querySelector('#pass2').focus()")
+
+ sessionRule.waitUntilCalled(object : Autofill.Delegate {
+ @AssertCalled(count = 1)
+ override fun onNodeFocus(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ screenRect = node.screenRect
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('iframe').contentDocument.querySelector('#pass2').focus()")
+
+ sessionRule.waitUntilCalled(object : Autofill.Delegate {
+ @AssertCalled(count = 1)
+ override fun onNodeFocus(
+ session: GeckoSession,
+ node: Autofill.Node,
+ data: Autofill.NodeData,
+ ) {
+ assertThat("ID should be valid", node, notNullValue())
+ // iframe's input element should consider iframe's offset. 200 is enough offset.
+ assertThat("position is valid", node.getScreenRect().top, greaterThanOrEqualTo(screenRect.top + 200))
+ }
+ })
+ }
+}
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..d2964aa54b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
@@ -0,0 +1,297 @@
+/* -*- 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.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 org.junit.rules.RuleChain
+import org.mozilla.geckoview.GeckoRuntimeSettings
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+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 CLIPBOARD_READ_HTML_PATH = "/assets/www/clipboard_read.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 FORMS_XORIGIN_HTML_PATH = "/assets/www/forms_xorigin.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 FORMS5_HTML_PATH = "/assets/www/forms5.html"
+ const val SELECT_HTML_PATH = "/assets/www/select.html"
+ const val SELECT_MULTIPLE_HTML_PATH = "/assets/www/select-multiple.html"
+ const val SELECT_LISTBOX_HTML_PATH = "/assets/www/select-listbox.html"
+ const val ADDRESS_FORM_HTML_PATH = "/assets/www/address_form.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 CC_FORM_HTML_PATH = "/assets/www/cc_form.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 METATAGS_PATH = "/assets/www/metatags.html"
+ const val MOUSE_TO_RELOAD_HTML_PATH = "/assets/www/mouseToReload.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 PRINT_CONTENT_CHANGE = "/assets/www/print_content_change.html"
+ const val PRINT_IFRAME = "/assets/www/print_iframe.html"
+ const val PROMPT_HTML_PATH = "/assets/www/prompts.html"
+ const val SAVE_STATE_PATH = "/assets/www/saveState.html"
+ const val TEST_GIF_PATH = "/assets/www/images/test.gif"
+ 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 = "https://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 TOUCH_XORIGIN_HTML_PATH = "/assets/www/touch_xorigin.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 TOUCH_ACTION_HTML_PATH = "/assets/www/touch-action.html"
+ const val TOUCH_ACTION_WHEEL_LISTENER_HTML_PATH = "/assets/www/touch-action-wheel-listener.html"
+ const val OVERSCROLL_BEHAVIOR_AUTO_HTML_PATH = "/assets/www/overscroll-behavior-auto.html"
+ const val OVERSCROLL_BEHAVIOR_AUTO_NONE_HTML_PATH = "/assets/www/overscroll-behavior-auto-none.html"
+ const val OVERSCROLL_BEHAVIOR_NONE_AUTO_HTML_PATH = "/assets/www/overscroll-behavior-none-auto.html"
+ const val OVERSCROLL_BEHAVIOR_NONE_NON_ROOT_HTML_PATH = "/assets/www/overscroll-behavior-none-on-non-root.html"
+ const val SCROLL_HANDOFF_HTML_PATH = "/assets/www/scroll-handoff.html"
+ const val SHOW_DYNAMIC_TOOLBAR_HTML_PATH = "/assets/www/showDynamicToolbar.html"
+ const val CONTEXT_MENU_AUDIO_HTML_PATH = "/assets/www/context_menu_audio.html"
+ const val CONTEXT_MENU_IMAGE_NESTED_HTML_PATH = "/assets/www/context_menu_image_nested.html"
+ const val CONTEXT_MENU_IMAGE_HTML_PATH = "/assets/www/context_menu_image.html"
+ const val CONTEXT_MENU_LINK_HTML_PATH = "/assets/www/context_menu_link.html"
+ const val CONTEXT_MENU_VIDEO_HTML_PATH = "/assets/www/context_menu_video.html"
+ const val CONTEXT_MENU_BLOB_FULL_HTML_PATH = "/assets/www/context_menu_blob_full.html"
+ const val CONTEXT_MENU_BLOB_BUFFERED_HTML_PATH = "/assets/www/context_menu_blob_buffered.html"
+ const val REMOTE_IFRAME = "/assets/www/accessibility/test-remote-iframe.html"
+ const val LOCAL_IFRAME = "/assets/www/accessibility/test-local-iframe.html"
+ const val BODY_FULLY_COVERED_BY_GREEN_ELEMENT = "/assets/www/red-background-body-fully-covered-by-green-element.html"
+ const val COLOR_GRID_HTML_PATH = "/assets/www/color_grid.html"
+ const val COLOR_ORANGE_BACKGROUND_HTML_PATH = "/assets/www/color_orange_background.html"
+ const val TRACEMONKEY_PDF_PATH = "/assets/www/tracemonkey.pdf"
+ const val HELLO_PDF_WORLD_PDF_PATH = "/assets/www/helloPDFWorld.pdf"
+ const val NO_META_VIEWPORT_HTML_PATH = "/assets/www/no-meta-viewport.html"
+
+ const val TEST_ENDPOINT = GeckoSessionTestRule.TEST_ENDPOINT
+ const val TEST_HOST = GeckoSessionTestRule.TEST_HOST
+ const val TEST_PORT = GeckoSessionTestRule.TEST_PORT
+ }
+
+ val sessionRule = GeckoSessionTestRule()
+
+ // Override this to include more `evaluate` rules in the chain
+ @get:Rule
+ open val rules = RuleChain.outerRule(sessionRule)
+
+ @get:Rule var temporaryProfile = TemporaryProfileRule()
+
+ @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.synthesizeMouseMove(x: Int, y: Int) =
+ sessionRule.synthesizeMouseMove(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)
+
+ fun GeckoSession.promiseAllPaintsDone() = sessionRule.promiseAllPaintsDone(this)
+
+ fun GeckoSession.getLinkColor(selector: String) = sessionRule.getLinkColor(this, selector)
+
+ fun GeckoSession.setResolutionAndScaleTo(resolution: Float) =
+ sessionRule.setResolutionAndScaleTo(this, resolution)
+
+ fun GeckoSession.triggerCookieBannerDetected() =
+ sessionRule.triggerCookieBannerDetected(this)
+
+ fun GeckoSession.triggerCookieBannerHandled() =
+ sessionRule.triggerCookieBannerHandled(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..d0ad03a439
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt
@@ -0,0 +1,302 @@
+/* -*- 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/ */
+
+// For ContentBlockingException
+@file:Suppress("DEPRECATION")
+
+package org.mozilla.geckoview.test
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.ContentBlocking
+import org.mozilla.geckoview.ContentBlocking.CookieBannerMode
+import org.mozilla.geckoview.ContentBlockingController
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentBlockingControllerTest : BaseSessionTest() {
+ // Smoke test for safe browsing settings, most testing is through platform tests
+ @Test
+ 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]),
+ )
+ }
+
+ @Test
+ fun getLog() {
+ val category = ContentBlocking.AntiTracking.TEST
+ sessionRule.runtime.settings.contentBlocking.setAntiTracking(category)
+ mainSession.settings.useTrackingProtection = true
+ mainSession.loadTestPath(TRACKERS_PATH)
+
+ sessionRule.waitUntilCalled(object : ContentBlocking.Delegate {
+ @AssertCalled(count = 1)
+ override fun onContentBlocked(
+ session: GeckoSession,
+ event: ContentBlocking.BlockEvent,
+ ) {
+ }
+ })
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.contentBlockingController.getLog(mainSession).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))
+ }
+ }
+ },
+ )
+ }
+
+ @Test
+ fun cookieBannerHandlingSettings() {
+ // Check default value
+
+ val contentBlocking = sessionRule.runtime.settings.contentBlocking
+
+ assertThat(
+ "Expect correct default value which is off",
+ contentBlocking.cookieBannerMode,
+ equalTo(CookieBannerMode.COOKIE_BANNER_MODE_DISABLED),
+ )
+ assertThat(
+ "Expect correct default value for private browsing",
+ contentBlocking.cookieBannerModePrivateBrowsing,
+ equalTo(CookieBannerMode.COOKIE_BANNER_MODE_REJECT),
+ )
+
+ // Checks that the pref value is also consistent with the runtime settings
+ val originalPrefs = sessionRule.getPrefs(
+ "cookiebanners.service.mode",
+ "cookiebanners.service.mode.privateBrowsing",
+ )
+
+ assertThat("Initial value is correct", originalPrefs[0] as Int, equalTo(contentBlocking.cookieBannerMode))
+ assertThat("Initial value is correct", originalPrefs[1] as Int, equalTo(contentBlocking.cookieBannerModePrivateBrowsing))
+
+ contentBlocking.cookieBannerMode = CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT
+ contentBlocking.cookieBannerModePrivateBrowsing = CookieBannerMode.COOKIE_BANNER_MODE_DISABLED
+
+ val actualPrefs = sessionRule.getPrefs(
+ "cookiebanners.service.mode",
+ "cookiebanners.service.mode.privateBrowsing",
+ )
+
+ assertThat("Initial value is correct", actualPrefs[0] as Int, equalTo(contentBlocking.cookieBannerMode))
+ assertThat("Initial value is correct", actualPrefs[1] as Int, equalTo(contentBlocking.cookieBannerModePrivateBrowsing))
+ }
+
+ @Test
+ fun cookieBannerHandlingDetectOnlyModeSettings() {
+ // Check default value
+ val contentBlocking = sessionRule.runtime.settings.contentBlocking
+
+ assertThat(
+ "Expect correct default value which is off",
+ contentBlocking.cookieBannerDetectOnlyMode,
+ equalTo(false),
+ )
+
+ // Checks that the pref value is also consistent with the runtime settings
+ val originalPrefs = sessionRule.getPrefs(
+ "cookiebanners.service.detectOnly",
+ )
+
+ assertThat(
+ "Initial value is correct",
+ originalPrefs[0] as Boolean,
+ equalTo(contentBlocking.cookieBannerDetectOnlyMode),
+ )
+
+ contentBlocking.cookieBannerDetectOnlyMode = true
+
+ val actualPrefs = sessionRule.getPrefs(
+ "cookiebanners.service.detectOnly",
+ )
+
+ assertThat(
+ "Initial value is correct",
+ actualPrefs[0] as Boolean,
+ equalTo(contentBlocking.cookieBannerDetectOnlyMode),
+ )
+ }
+}
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..868491cd85
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt
@@ -0,0 +1,51 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers
+import org.junit.After
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeThat
+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.GeckoRuntime
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentCrashTest : BaseSessionTest() {
+ val client = TestCrashHandler.Client(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ @Before
+ fun setup() {
+ assertTrue(client.connect(env.defaultTimeoutMillis))
+ client.setEvalNextCrashDump(GeckoRuntime.CRASHED_PROCESS_TYPE_FOREGROUND_CHILD)
+ }
+
+ @IgnoreCrash
+ @Test
+ fun crashContent() {
+ // We need the crash reporter for this test
+ assumeTrue(BuildConfig.MOZ_CRASHREPORTER)
+
+ // TODO: bug 1710940
+ assumeThat(sessionRule.env.isIsolatedProcess, Matchers.equalTo(false))
+
+ mainSession.loadUri(CONTENT_CRASH_URL)
+ mainSession.waitUntilCalled(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/ContentDelegateChildTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt
new file mode 100644
index 0000000000..f560a2af22
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt
@@ -0,0 +1,278 @@
+/* -*- 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.* // ktlint-disable no-wildcard-imports
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assert.assertNull
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentDelegateChildTest : BaseSessionTest() {
+
+ private fun sendLongPress(x: Float, y: Float) {
+ val downTime = SystemClock.uptimeMillis()
+ var eventTime = SystemClock.uptimeMillis()
+ var event = MotionEvent.obtain(
+ downTime,
+ eventTime,
+ MotionEvent.ACTION_DOWN,
+ x,
+ y,
+ 0,
+ )
+ mainSession.panZoomController.onTouchEvent(event)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun requestContextMenuOnAudio() {
+ mainSession.loadTestPath(CONTEXT_MENU_AUDIO_HTML_PATH)
+ mainSession.waitForPageStop()
+ sendLongPress(0f, 0f)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+
+ @AssertCalled(count = 1)
+ override fun onContextMenu(
+ session: GeckoSession,
+ screenX: Int,
+ screenY: Int,
+ element: ContextElement,
+ ) {
+ assertThat(
+ "Type should be audio.",
+ element.type,
+ equalTo(ContextElement.TYPE_AUDIO),
+ )
+ assertThat(
+ "The element source should be the mp3 file.",
+ element.srcUri,
+ endsWith("owl.mp3"),
+ )
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun requestContextMenuOnBlobBuffered() {
+ // Bug 1810736
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+ mainSession.loadTestPath(CONTEXT_MENU_BLOB_BUFFERED_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.waitForRoundTrip()
+ sendLongPress(50f, 50f)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+
+ @AssertCalled(count = 1)
+ override fun onContextMenu(
+ session: GeckoSession,
+ screenX: Int,
+ screenY: Int,
+ element: ContextElement,
+ ) {
+ assertThat(
+ "Type should be video.",
+ element.type,
+ equalTo(ContextElement.TYPE_VIDEO),
+ )
+ assertNull(
+ "Buffered blob should not have a srcUri.",
+ element.srcUri,
+ )
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun requestContextMenuOnBlobFull() {
+ mainSession.loadTestPath(CONTEXT_MENU_BLOB_FULL_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.waitForRoundTrip()
+ sendLongPress(50f, 50f)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+
+ @AssertCalled(count = 1)
+ override fun onContextMenu(
+ session: GeckoSession,
+ screenX: Int,
+ screenY: Int,
+ element: ContextElement,
+ ) {
+ assertThat(
+ "Type should be image.",
+ element.type,
+ equalTo(ContextElement.TYPE_IMAGE),
+ )
+ assertThat(
+ "Alternate text should match.",
+ element.altText,
+ equalTo("An orange circle."),
+ )
+ assertThat(
+ "The element source should begin with blob.",
+ element.srcUri,
+ startsWith("blob:"),
+ )
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun requestContextMenuOnImageNested() {
+ mainSession.loadTestPath(CONTEXT_MENU_IMAGE_NESTED_HTML_PATH)
+ mainSession.waitForPageStop()
+ sendLongPress(50f, 50f)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+
+ @AssertCalled(count = 1)
+ override fun onContextMenu(
+ session: GeckoSession,
+ screenX: Int,
+ screenY: Int,
+ element: ContextElement,
+ ) {
+ assertThat(
+ "Type should be image.",
+ element.type,
+ equalTo(ContextElement.TYPE_IMAGE),
+ )
+ assertThat(
+ "Alternate text should match.",
+ element.altText,
+ equalTo("Test Image"),
+ )
+ assertThat(
+ "The element source should be the image file.",
+ element.srcUri,
+ endsWith("test.gif"),
+ )
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun requestContextMenuOnImage() {
+ mainSession.loadTestPath(CONTEXT_MENU_IMAGE_HTML_PATH)
+ mainSession.waitForPageStop()
+ sendLongPress(50f, 50f)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+
+ @AssertCalled(count = 1)
+ override fun onContextMenu(
+ session: GeckoSession,
+ screenX: Int,
+ screenY: Int,
+ element: ContextElement,
+ ) {
+ assertThat(
+ "Type should be image.",
+ element.type,
+ equalTo(ContextElement.TYPE_IMAGE),
+ )
+ assertThat(
+ "Alternate text should match.",
+ element.altText,
+ equalTo("Test Image"),
+ )
+ assertThat(
+ "The element source should be the image file.",
+ element.srcUri,
+ endsWith("test.gif"),
+ )
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun requestContextMenuOnLink() {
+ mainSession.loadTestPath(CONTEXT_MENU_LINK_HTML_PATH)
+ mainSession.waitForPageStop()
+ sendLongPress(50f, 50f)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onContextMenu(
+ session: GeckoSession,
+ screenX: Int,
+ screenY: Int,
+ element: ContextElement,
+ ) {
+ assertThat(
+ "Type should be none.",
+ element.type,
+ equalTo(ContextElement.TYPE_NONE),
+ )
+ assertThat(
+ "The element link title should be the title of the anchor.",
+ element.title,
+ equalTo("Hello Link Title"),
+ )
+ assertThat(
+ "The element link URI should be the href of the anchor.",
+ element.linkUri,
+ endsWith("hello.html"),
+ )
+ assertThat(
+ "The element link text content should be the text content of the anchor.",
+ element.textContent,
+ equalTo("Hello World"),
+ )
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun requestContextMenuOnVideo() {
+ // Bug 1700243
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+ mainSession.loadTestPath(CONTEXT_MENU_VIDEO_HTML_PATH)
+ mainSession.waitForPageStop()
+ sendLongPress(50f, 50f)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+
+ @AssertCalled(count = 1)
+ override fun onContextMenu(
+ session: GeckoSession,
+ screenX: Int,
+ screenY: Int,
+ element: ContextElement,
+ ) {
+ assertThat(
+ "Type should be video.",
+ element.type,
+ equalTo(ContextElement.TYPE_VIDEO),
+ )
+ assertThat(
+ "The element source should be the video file.",
+ element.srcUri,
+ endsWith("short.mp4"),
+ )
+ }
+ })
+ }
+}
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..a871c09a5a
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt
@@ -0,0 +1,161 @@
+/* -*- 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.annotation.AnyThread
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentDelegateMultipleSessionsTest : BaseSessionTest() {
+ val contentProcNameRegex = ".*:tab\\d+$".toRegex()
+
+ @AnyThread
+ fun killAllContentProcesses() {
+ val contentProcessPids = sessionRule.getAllSessionPids()
+ for (pid in contentProcessPids) {
+ sessionRule.killContentProcess(pid)
+ }
+ }
+
+ fun resetContentProcesses() {
+ val isMainSessionAlreadyOpen = mainSession.isOpen()
+ killAllContentProcesses()
+
+ if (isMainSessionAlreadyOpen) {
+ mainSession.waitUntilCalled(object : 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 : 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 : 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..65a07d384d
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
@@ -0,0 +1,660 @@
+/* -*- 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.SurfaceTexture
+import android.net.Uri
+import android.view.PointerIcon
+import android.view.Surface
+import androidx.annotation.AnyThread
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.ContentBlocking.CookieBannerMode
+import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import java.io.ByteArrayInputStream
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ContentDelegateTest : BaseSessionTest() {
+ @Test fun titleChange() {
+ mainSession.loadTestPath(TITLE_CHANGE_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 2)
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat(
+ "Title should match",
+ title,
+ equalTo(forEachCall("Title1", "Title2")),
+ )
+ }
+ })
+ }
+
+ @Test fun openInAppRequest() {
+ // Testing WebResponse behavior
+ val data = "Hello, World.".toByteArray()
+ val fileHeader = "attachment; filename=\"hello-world.txt\""
+ val requestExternal = true
+ val skipConfirmation = true
+ var response = WebResponse.Builder(HELLO_HTML_PATH)
+ .statusCode(200)
+ .body(ByteArrayInputStream(data))
+ .addHeader("Content-Type", "application/txt")
+ .addHeader("Content-Length", data.size.toString())
+ .addHeader("Content-Disposition", fileHeader)
+ .requestExternalApp(requestExternal)
+ .skipConfirmation(skipConfirmation)
+ .build()
+ assertThat(
+ "Filename matches as expected",
+ response.headers["Content-Disposition"],
+ equalTo(fileHeader),
+ )
+ assertThat(
+ "Request external response matches as expected.",
+ requestExternal,
+ equalTo(response.requestExternalApp),
+ )
+ assertThat(
+ "Skipping the confirmation matches as expected.",
+ skipConfirmation,
+ equalTo(response.skipConfirmation),
+ )
+ }
+
+ @Test fun downloadOneRequest() {
+ // disable test on pgo for frequently failing Bug 1543355
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(true))
+
+ mainSession.loadTestPath(DOWNLOAD_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : NavigationDelegate, 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\""))
+ assertThat("Request external response should not be set.", response.requestExternalApp, equalTo(false))
+ assertThat("Should not skip the confirmation on a regular download.", response.skipConfirmation, equalTo(false))
+ }
+ })
+ }
+
+ @IgnoreCrash
+ @Test
+ fun crashContent() {
+ // TODO: bug 1710940
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ mainSession.loadUri(CONTENT_CRASH_URL)
+ mainSession.waitUntilCalled(object : 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 : 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() {
+ // TODO: bug 1710940
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ mainSession.delegateUntilTestEnd(object : 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 contentProcessPids = sessionRule.getAllSessionPids()
+ for (pid in contentProcessPids) {
+ sessionRule.killContentProcess(pid)
+ }
+ }
+
+ @IgnoreCrash
+ @Test
+ fun killContent() {
+ killAllContentProcesses()
+ mainSession.waitUntilCalled(object : 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 : 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 : 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 : 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(SurfaceInfo.Builder(surface).size(100, 100).build())
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstComposite(session: GeckoSession) {
+ }
+ })
+ display.surfaceDestroyed()
+ display.surfaceChanged(SurfaceInfo.Builder(surface).size(100, 100).build())
+ sessionRule.waitUntilCalled(object : 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 : 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 : ContentDelegate, ProgressDelegate {
+ @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 previewImage() {
+ mainSession.loadTestPath(METATAGS_PATH)
+ mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPreviewImage(session: GeckoSession, previewImageUrl: String) {
+ assertThat("Preview image should match", previewImageUrl, equalTo("https://test.com/og-image-url"))
+ }
+ })
+ }
+
+ @Test fun viewportFit() {
+ mainSession.loadTestPath(VIEWPORT_PATH)
+ mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate {
+ @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 : ContentDelegate, ProgressDelegate {
+ @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 : 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 : 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 : ContentDelegate, ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onCloseRequest(session: GeckoSession) {
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun cookieBannerDetectedEvent() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT,
+ ),
+ )
+
+ val detectHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate {
+ override fun onCookieBannerDetected(
+ session: GeckoSession,
+ ) {
+ detectHandled.complete(null)
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.triggerCookieBannerDetected()
+
+ sessionRule.waitForResult(detectHandled)
+ }
+
+ @Test fun cookieBannerHandledEvent() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT,
+ ),
+ )
+
+ val handleHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate {
+ override fun onCookieBannerHandled(
+ session: GeckoSession,
+ ) {
+ handleHandled.complete(null)
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.triggerCookieBannerHandled()
+
+ sessionRule.waitForResult(handleHandled)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun setCursor() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.body.style.cursor = 'wait'")
+ mainSession.synthesizeMouseMove(50, 50)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onPointerIconChange(session: GeckoSession, icon: PointerIcon) {
+ // PointerIcon has no compare method.
+ }
+ })
+
+ val delegate = mainSession.contentDelegate
+ mainSession.contentDelegate = null
+ mainSession.evaluateJS("document.body.style.cursor = 'text'")
+ for (i in 51..70) {
+ mainSession.synthesizeMouseMove(i, 50)
+ // No wait function since we remove content delegate.
+ mainSession.waitForJS("new Promise(resolve => window.setTimeout(resolve, 100))")
+ }
+ mainSession.contentDelegate = delegate
+ }
+
+ /**
+ * Preferences to induce wanted behaviour.
+ */
+ private fun setHangReportTestPrefs(timeout: Int = 20000) {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "dom.max_script_run_time" 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(ContentDelegate::class)
+ @Test
+ fun stopHungProcessDefault() {
+ setHangReportTestPrefs()
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.delegateUntilTestEnd(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat(
+ "The script did not complete.",
+ mainSession.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 : ContentDelegate, ProgressDelegate {
+ // default onSlowScript returns null
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat(
+ "The script did not complete.",
+ mainSession.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 : ContentDelegate, 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.",
+ mainSession.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 : ContentDelegate, 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.",
+ mainSession.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 : ContentDelegate, 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.",
+ mainSession.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 : ContentDelegate, 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.",
+ mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String,
+ equalTo("Started"),
+ )
+ }
+ })
+ mainSession.loadTestPath(HUNG_SCRIPT)
+ sessionRule.waitForPageStop(mainSession)
+ }
+
+ /**
+ * Test that the display mode is applied to CSS media query
+ */
+ @Test fun displayMode() {
+ val pwaSession = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .displayMode(GeckoSessionSettings.DISPLAY_MODE_FULLSCREEN)
+ .build(),
+ )
+ pwaSession.loadTestPath(HELLO_HTML_PATH)
+ pwaSession.waitForPageStop()
+
+ val matches = pwaSession.evaluateJS("window.matchMedia('(display-mode: fullscreen)').matches") as Boolean
+ assertThat(
+ "display-mode should be fullscreen",
+ matches,
+ equalTo(true),
+ )
+ }
+}
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..86c8e9cac6
--- /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.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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)
+ }
+ }
+}
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..6a79df6173
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt
@@ -0,0 +1,727 @@
+/* -*- 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.* // ktlint-disable no-wildcard-imports
+import android.graphics.Bitmap
+import android.os.SystemClock
+import android.util.Base64
+import android.view.MotionEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.hamcrest.Matchers.closeTo
+import org.hamcrest.Matchers.equalTo
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.GeckoSession.ScrollDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import java.io.ByteArrayOutputStream
+
+private const val SCREEN_WIDTH = 100
+private const val SCREEN_HEIGHT = 200
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class DynamicToolbarTest : BaseSessionTest() {
+ // Makes sure we can load a page when the dynamic toolbar is bigger than the whole content
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ 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 = mainSession.evaluateJS("window.devicePixelRatio") as Double
+ val scale = mainSession.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 = mainSession.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 = mainSession.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 = mainSession.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 = mainSession.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 = mainSession.evaluateJS("window.devicePixelRatio") as Double
+
+ for (i in 1..dynamicToolbarMaxHeight - 1) {
+ val promise = mainSession.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 = mainSession.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 = mainSession.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))
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun showDynamicToolbar() {
+ 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(SHOW_DYNAMIC_TOOLBAR_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("window.scrollTo(0, " + dynamicToolbarMaxHeight + ")")
+ mainSession.waitUntilCalled(object : ScrollDelegate {
+ @AssertCalled(count = 1)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+
+ // Simulate the dynamic toolbar being hidden by the scroll
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+
+ mainSession.synthesizeTap(5, 25)
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowDynamicToolbar(session: GeckoSession) {
+ }
+ })
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun showDynamicToolbarOnOverflowHidden() {
+ 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(SHOW_DYNAMIC_TOOLBAR_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("window.scrollTo(0, " + dynamicToolbarMaxHeight + ")")
+ mainSession.waitUntilCalled(object : ScrollDelegate {
+ @AssertCalled(count = 1)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+
+ // Simulate the dynamic toolbar being hidden by the scroll
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+
+ mainSession.evaluateJS("document.documentElement.style.overflow = 'hidden'")
+
+ mainSession.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowDynamicToolbar(session: GeckoSession) {
+ }
+ })
+ }
+
+ private fun getComputedViewportHeight(style: String): Double {
+ val viewportHeight = mainSession.evaluateJS(
+ """
+ const target = document.createElement('div');
+ target.style.height = '$style';
+ document.body.appendChild(target);
+ parseFloat(getComputedStyle(target).height);
+ """.trimIndent(),
+ ) as Double
+
+ return viewportHeight
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun viewportVariants() {
+ 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.VIEWPORT_PATH)
+ mainSession.waitForPageStop()
+
+ val pixelRatio = mainSession.evaluateJS("window.devicePixelRatio") as Double
+ val scale = mainSession.evaluateJS("window.visualViewport.scale") as Double
+
+ var smallViewportHeight = getComputedViewportHeight("100svh")
+ assertThat(
+ "svh value at the initial state",
+ smallViewportHeight,
+ closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight) / scale / pixelRatio, 0.1),
+ )
+
+ var largeViewportHeight = getComputedViewportHeight("100lvh")
+ assertThat(
+ "lvh value at the initial state",
+ largeViewportHeight,
+ closeTo(SCREEN_HEIGHT / scale / pixelRatio, 0.1),
+ )
+
+ var dynamicViewportHeight = getComputedViewportHeight("100dvh")
+ assertThat(
+ "dvh value at the initial state",
+ dynamicViewportHeight,
+ closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight) / scale / pixelRatio, 0.1),
+ )
+
+ // Move down the toolbar at a fourth of its position.
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight / 4) }
+
+ smallViewportHeight = getComputedViewportHeight("100svh")
+ assertThat(
+ "svh value during toolbar transition",
+ smallViewportHeight,
+ closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight) / scale / pixelRatio, 0.1),
+ )
+
+ largeViewportHeight = getComputedViewportHeight("100lvh")
+ assertThat(
+ "lvh value during toolbar transition",
+ largeViewportHeight,
+ closeTo(SCREEN_HEIGHT / scale / pixelRatio, 0.1),
+ )
+
+ dynamicViewportHeight = getComputedViewportHeight("100dvh")
+ assertThat(
+ "dvh value during toolbar transition",
+ dynamicViewportHeight,
+ closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight + dynamicToolbarMaxHeight / 4) / scale / pixelRatio, 0.1),
+ )
+ }
+
+ // With dynamic toolbar, there was a floating point rounding error in Gecko layout side.
+ // The error was appeared by user interactive async scrolling, not by programatic async
+ // scrolling, e.g. scrollTo() method. If the error happens there will appear 1px gap
+ // between <body> and an element which covers up the <body> element.
+ // This test simulates the situation.
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun noGapAppearsBetweenBodyAndElementFullyCoveringBody() {
+ // Bug 1764219 - disable the test to reduce intermittent failure rate
+ assumeThat(sessionRule.env.isDebugBuild, equalTo(false))
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ mainSession.loadTestPath(BaseSessionTest.BODY_FULLY_COVERED_BY_GREEN_ELEMENT)
+ mainSession.waitForPageStop()
+ mainSession.flushApzRepaints()
+
+ // Scrolling down by touch events.
+ var downTime = SystemClock.uptimeMillis()
+ var down = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_DOWN,
+ 50f,
+ 70f,
+ 0,
+ )
+ mainSession.panZoomController.onTouchEvent(down)
+ var move = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_MOVE,
+ 50f,
+ 30f,
+ 0,
+ )
+ mainSession.panZoomController.onTouchEvent(move)
+ var up = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_UP,
+ 50f,
+ 10f,
+ 0,
+ )
+ mainSession.panZoomController.onTouchEvent(up)
+ mainSession.flushApzRepaints()
+
+ // Scrolling up by touch events to restore the original position.
+ downTime = SystemClock.uptimeMillis()
+ down = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_DOWN,
+ 50f,
+ 10f,
+ 0,
+ )
+ mainSession.panZoomController.onTouchEvent(down)
+ move = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_MOVE,
+ 50f,
+ 30f,
+ 0,
+ )
+ mainSession.panZoomController.onTouchEvent(move)
+ up = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_UP,
+ 50f,
+ 70f,
+ 0,
+ )
+ mainSession.panZoomController.onTouchEvent(up)
+ mainSession.flushApzRepaints()
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), reference)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun zoomedOverflowHidden() {
+ val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for foreground tab.
+ mainSession.setActive(true)
+
+ mainSession.loadTestPath(BaseSessionTest.FIXED_BOTTOM)
+ mainSession.waitForPageStop()
+
+ // Change the body background color to match the reference image's background color.
+ mainSession.evaluateJS("document.body.style.background = 'rgb(0, 128, 0)'")
+
+ // Hide the vertical scrollbar.
+ mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'")
+
+ // Zoom in the content so that the content's visual viewport can be scrollable.
+ mainSession.setResolutionAndScaleTo(10.0f)
+
+ // Simulate the dynamic toolbar being hidden by the scroll
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+
+ mainSession.flushApzRepaints()
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), reference)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun zoomedPositionFixedRoot() {
+ val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) }
+
+ // Set active since setVerticalClipping call affects only for foreground tab.
+ mainSession.setActive(true)
+
+ mainSession.loadTestPath(BaseSessionTest.FIXED_BOTTOM)
+ mainSession.waitForPageStop()
+
+ // Change the body background color to match the reference image's background color.
+ mainSession.evaluateJS("document.body.style.background = 'rgb(0, 128, 0)'")
+
+ // Change the root `overlow` style to make it scrollable and change the position style
+ // to `fixed` so that the root container is not scrollable.
+ mainSession.evaluateJS("document.body.style.overflow = 'scroll'")
+ mainSession.evaluateJS("document.documentElement.style.position = 'fixed'")
+
+ // Hide the vertical scrollbar.
+ mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'")
+
+ // Zoom in the content so that the content's visual viewport can be scrollable.
+ mainSession.setResolutionAndScaleTo(10.0f)
+
+ // Simulate the dynamic toolbar being hidden by the scroll
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+
+ mainSession.flushApzRepaints()
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), reference)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun backgroundImageFixed() {
+ val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ 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.TOUCH_ACTION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // Specify the root background-color to match the reference image color and specify
+ // `background-attachment: fixed`.
+ mainSession.evaluateJS("document.documentElement.style.background = 'linear-gradient(green, green) fixed'")
+
+ // Make the root element scrollable.
+ mainSession.evaluateJS("document.documentElement.style.height = '100vh'")
+
+ // Hide the vertical scrollbar.
+ mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'")
+
+ mainSession.flushApzRepaints()
+
+ // Simulate the dynamic toolbar being hidden by the scroll
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+
+ mainSession.flushApzRepaints()
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), reference)
+ }
+ }
+
+ @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH)
+ @Test
+ fun backgroundAttachmentFixed() {
+ val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT)
+
+ 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.TOUCH_ACTION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // Specify the root background-color to match the reference image color and specify
+ // `background-attachment: fixed`.
+ mainSession.evaluateJS("document.documentElement.style.background = 'rgb(0, 128, 0) fixed'")
+
+ // Make the root element scrollable.
+ mainSession.evaluateJS("document.documentElement.style.height = '100vh'")
+
+ // Hide the vertical scrollbar.
+ mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'")
+
+ mainSession.flushApzRepaints()
+
+ // Simulate the dynamic toolbar being hidden by the scroll
+ sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) }
+
+ mainSession.flushApzRepaints()
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), reference)
+ }
+ }
+}
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..833d8091fa
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt
@@ -0,0 +1,878 @@
+package org.mozilla.geckoview.test
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.equalTo
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assume.assumeThat
+import org.junit.Before
+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.Image.ImageProcessingException
+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 otherExtension: 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/"),
+ )
+ // Another dummy extension, only used to check restrictions related to setting
+ // another extension url as a popup url, and so there is no delegate needed for it.
+ otherExtension = sessionRule.waitForResult(
+ controller.installBuiltIn("resource://android/assets/web_extensions/dummy/"),
+ )
+
+ mainSession.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)
+ }
+ },
+ )
+
+ mainSession.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!!))
+ }
+
+ if (otherExtension != null) {
+ sessionRule.waitForResult(controller.uninstall(otherExtension!!))
+ }
+ }
+
+ 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 testSetPopup(popupUrl: String, isUrlAllowed: Boolean) {
+ val setPopupResult = GeckoResult<Void>()
+
+ backgroundPort!!.setDelegate(object : WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any, port: WebExtension.Port) {
+ val json = message as JSONObject
+ if (json.getString("resultFor") == "setPopup" &&
+ json.getString("type") == type
+ ) {
+ if (isUrlAllowed != json.getBoolean("success")) {
+ val expectedResString = when (isUrlAllowed) {
+ true -> "allowed"
+ else -> "disallowed"
+ }
+ setPopupResult.completeExceptionally(
+ IllegalArgumentException(
+ "Expected \"${popupUrl}\" to be ${ expectedResString }",
+ ),
+ )
+ } else {
+ setPopupResult.complete(null)
+ }
+ } else {
+ // We should NOT receive the expected message result.
+ setPopupResult.completeExceptionally(
+ IllegalArgumentException(
+ "Received unexpected result for: ${json.getString("type")} ${json.getString("resultFor")}",
+ ),
+ )
+ }
+ }
+ })
+
+ var json = JSONObject(
+ """{
+ "action": "setPopupCheckRestrictions",
+ "popup": "$popupUrl"
+ }""",
+ )
+
+ json.put("type", type)
+ windowPort!!.postMessage(json)
+
+ sessionRule.waitForResult(setPopupResult)
+ }
+
+ 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 ->
+ mainSession.webExtensionController.setActionDelegate(extension!!, delegate)
+ },
+ { mainSession.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 ->
+ if (!(exception is ImageProcessingException)) {
+ throw exception!!
+ }
+ error.complete(null)
+ })
+ }
+
+ sessionRule.waitForResult(error)
+ }
+
+ @Test
+ fun testSetPopupRestrictions() {
+ testSetPopup("https://example.com", false)
+ testSetPopup("${otherExtension!!.metaData.baseUrl}other-extension.html", false)
+ testSetPopup("${extension!!.metaData.baseUrl}same-extension.html", true)
+ testSetPopup("relative-url-01.html", true)
+ testSetPopup("/relative-url-02.html", true)
+ }
+
+ @Test
+ @GeckoSessionTestRule.WithDisplay(width = 100, height = 100)
+ 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)
+ }
+ sessionRule.waitForResult(actionResult)
+
+ val url = when (id) {
+ "#browserAction" -> "test-open-popup-browser-action.html"
+ "#pageAction" -> "test-open-popup-page-action.html"
+ else -> throw IllegalArgumentException()
+ }
+
+ var location = extension!!.metaData.baseUrl
+ mainSession.loadUri("$location$url")
+ sessionRule.waitForPageStop()
+
+ val openPopup = GeckoResult<Void>()
+ mainSession.webExtensionController.setActionDelegate(
+ extension!!,
+ object : WebExtension.ActionDelegate {
+ override fun onOpenPopup(
+ extension: WebExtension,
+ popupAction: WebExtension.Action,
+ ): GeckoResult<GeckoSession>? {
+ assertEquals(extension, this@ExtensionActionTest.extension)
+ openPopup.complete(null)
+ return null
+ }
+ },
+ )
+
+ // openPopup needs user activation
+ mainSession.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 testPopupMessaging() {
+ val popupSession = sessionRule.createOpenSession()
+
+ val actionResult = GeckoResult<WebExtension.Action>()
+ testActionApi(
+ """{
+ "action": "setPopup",
+ "popup": "test-popup-messaging.html"
+ }""",
+ ) { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+ actionResult.complete(action)
+ }
+
+ val messages = mutableListOf<String>()
+ val messageResult = GeckoResult<List<String>>()
+ val portResult = GeckoResult<WebExtension.Port>()
+ 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,
+ )
+ assertEquals(sender.isTopLevel, true)
+ assertEquals(
+ "${extension!!.metaData.baseUrl}test-popup-messaging.html",
+ sender.url,
+ )
+ assertEquals(sender.session, popupSession)
+ messages.add(message as String)
+ if (messages.size == 2) {
+ messageResult.complete(messages)
+ return null
+ } else {
+ return GeckoResult.fromValue("TEST_RESPONSE")
+ }
+ }
+
+ override fun onConnect(port: WebExtension.Port) {
+ assertEquals(extension!!.id, port.sender.webExtension.id)
+ assertEquals(
+ WebExtension.MessageSender.ENV_TYPE_EXTENSION,
+ port.sender.environmentType,
+ )
+ assertEquals(true, port.sender.isTopLevel)
+ assertEquals(
+ "${extension!!.metaData.baseUrl}test-popup-messaging.html",
+ port.sender.url,
+ )
+ assertEquals(port.sender.session, popupSession)
+ portResult.complete(port)
+ }
+ }
+
+ popupSession.webExtensionController.setMessageDelegate(
+ extension!!,
+ messageDelegate,
+ "browser",
+ )
+
+ 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)
+ return GeckoResult.fromValue(popupSession)
+ }
+ })
+
+ action.click()
+
+ val message = sessionRule.waitForResult(messageResult)
+ assertThat(
+ "Message should match",
+ message,
+ equalTo(
+ listOf(
+ "testPopupMessage",
+ "response: TEST_RESPONSE",
+ ),
+ ),
+ )
+
+ val port = sessionRule.waitForResult(portResult)
+ val portMessageResult = GeckoResult<String>()
+
+ port.setDelegate(object : WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any, p: WebExtension.Port) {
+ assertEquals(port, p)
+ portMessageResult.complete(message as String)
+ }
+ })
+
+ val portMessage = sessionRule.waitForResult(portMessageResult)
+ assertThat(
+ "Message should match",
+ portMessage,
+ equalTo("testPopupPortMessage"),
+ )
+ }
+
+ @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..beff344ef7
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt
@@ -0,0 +1,456 @@
+/* -*- 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.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoSession
+
+@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),
+ )
+ }
+
+ @Test fun find_in_pdf() {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_PATH)
+ mainSession.waitForPageStop()
+
+ // Initial search.
+ var result = sessionRule.waitForResult(mainSession.finder.find("trace", 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(141))
+ assertThat(
+ "Search string should be correct",
+ result.searchString,
+ equalTo("trace"),
+ )
+ 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 not have wrapped", result.wrapped, equalTo(false))
+ assertThat("Current count should be correct", result.current, equalTo(6))
+ assertThat("Total count should be correct", result.total, equalTo(85))
+ assertThat(
+ "Search string should be correct",
+ result.searchString,
+ equalTo("trace"),
+ )
+ 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(5))
+ assertThat("Total count should be correct", result.total, equalTo(85))
+ assertThat(
+ "Search string should be correct",
+ result.searchString,
+ equalTo("trace"),
+ )
+ 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(6))
+ assertThat("Total count should be correct", result.total, equalTo(85))
+ assertThat(
+ "Search string should be correct",
+ result.searchString,
+ equalTo("trace"),
+ )
+ assertThat(
+ "Flags should be correct",
+ result.flags,
+ equalTo(
+ GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD,
+ ),
+ )
+ }
+
+ @Test fun find_in_pdf_with_wrapped_result() {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_PATH)
+ mainSession.waitForPageStop()
+
+ // Initial search.
+ var result = sessionRule.waitForResult(
+ mainSession.finder.find(
+ "SpiderMonkey",
+ GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD,
+ ),
+ )
+
+ for (count in 1..4) {
+ assertThat("Should be found", result.found, equalTo(true))
+ assertThat("Should (not) have wrapped", result.wrapped, equalTo(count == 4))
+ assertThat("Current count should be correct", result.current, equalTo(if (count == 4) 1 else count))
+ assertThat("Total count should be correct", result.total, equalTo(3))
+ assertThat(
+ "Search string should be correct",
+ result.searchString,
+ equalTo("SpiderMonkey"),
+ )
+
+ // And again.
+ result = sessionRule.waitForResult(
+ mainSession.finder.find(
+ null,
+ GeckoSession.FINDER_FIND_MATCH_CASE
+ or GeckoSession.FINDER_FIND_WHOLE_WORD,
+ ),
+ )
+ }
+ }
+
+ @Test fun find_in_pdf_notFound() {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_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("Spi", 0))
+
+ assertThat("Should be found", result.found, equalTo(true))
+ }
+
+ @Test fun find_in_pdf_matchCase() {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_PATH)
+ mainSession.waitForPageStop()
+
+ var result = sessionRule.waitForResult(mainSession.finder.find("language", 0))
+
+ assertThat("Total count should be correct", result.total, equalTo(15))
+
+ result = sessionRule.waitForResult(
+ mainSession.finder.find(
+ null,
+ GeckoSession.FINDER_FIND_MATCH_CASE,
+ ),
+ )
+
+ assertThat("Total count should be correct", result.total, equalTo(13))
+ assertThat(
+ "Flags should be correct",
+ result.flags,
+ equalTo(GeckoSession.FINDER_FIND_MATCH_CASE),
+ )
+ }
+
+ @Test fun find_in_pdf_wholeWord() {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_PATH)
+ mainSession.waitForPageStop()
+
+ var result = sessionRule.waitForResult(mainSession.finder.find("speed", 0))
+
+ assertThat("Total count should be correct", result.total, equalTo(5))
+
+ result = sessionRule.waitForResult(
+ mainSession.finder.find(
+ null,
+ GeckoSession.FINDER_FIND_WHOLE_WORD,
+ ),
+ )
+
+ assertThat("Total count should be correct", result.total, equalTo(1))
+ assertThat(
+ "Flags should be correct",
+ result.flags,
+ equalTo(GeckoSession.FINDER_FIND_WHOLE_WORD),
+ )
+ }
+
+ @Test fun find_in_pdf_and_html() {
+ for (i in 1..2) {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_PATH)
+ mainSession.waitForPageStop()
+
+ var result = sessionRule.waitForResult(mainSession.finder.find("trace", 0))
+
+ assertThat("Total count should be correct", result.total, equalTo(141))
+
+ mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ result = sessionRule.waitForResult(mainSession.finder.find("dolore", 0))
+
+ assertThat("Total count should be correct", result.total, equalTo(2))
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt
new file mode 100644
index 0000000000..c05820012d
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt
@@ -0,0 +1,120 @@
+/* -*- 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 android.provider.Settings
+import android.text.format.DateFormat
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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.gecko.GeckoAppShell
+import org.mozilla.geckoview.Autofill
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class GeckoAppShellTest : BaseSessionTest() {
+ private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+ private var prior24HourSetting = true
+
+ @get:Rule
+ override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ @Before
+ fun setup() {
+ activityRule.scenario.onActivity {
+ prior24HourSetting = DateFormat.is24HourFormat(context)
+ it.view.setSession(sessionRule.session)
+ }
+ }
+
+ @After
+ fun cleanup() {
+ activityRule.scenario.onActivity {
+ // Return the test harness back to original setting
+ setAndroid24HourTimeFormat(prior24HourSetting)
+ it.view.releaseSession()
+ }
+ }
+
+ // Sets the Android system is24HourFormat preference
+ private fun setAndroid24HourTimeFormat(timeFormat: Boolean) {
+ val setting = if (timeFormat) "24" else "12"
+ Settings.System.putString(context.contentResolver, Settings.System.TIME_12_24, setting)
+ }
+
+ // Sends app to background, then to foreground, and finally loads a page
+ private fun goHomeAndReturnWithPageLoad() {
+ // Ensures a return to the foreground (onResume)
+ Handler(Looper.getMainLooper()).postDelayed({
+ sessionRule.requestActivityToForeground(context)
+ // Will call onLoadRequest and allow test to finish
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ }, 1500)
+
+ // Will cause onPause event to occur
+ sessionRule.simulatePressHome(context)
+ }
+
+ @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun testChange24HourClockSettings() {
+ activityRule.scenario.onActivity {
+ var onLoadRequestCount = 0
+
+ // First clock settings change, takes effect on next onResume
+ // Time format that does not use AM/PM, e.g., 13:00
+ setAndroid24HourTimeFormat(true)
+ // Causes an onPause event, onResume event, and finally a page load request
+ goHomeAndReturnWithPageLoad()
+
+ // This is waiting and holding the test harness open while Android Lifecycle events complete
+ mainSession.waitUntilCalled(object : GeckoSession.ContentDelegate, GeckoSession.NavigationDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 2)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<GeckoSession.PermissionDelegate.ContentPermission>,
+ ) {
+ // Result of first clock settings change
+ if (onLoadRequestCount == 0) {
+ assertThat(
+ "Should use a 24 hour clock.",
+ GeckoAppShell.getIs24HourFormat(),
+ equalTo(true),
+ )
+ onLoadRequestCount++
+
+ // Calling second clock settings change
+ // Time format that does use AM/PM, e.g., 1:00 PM
+ setAndroid24HourTimeFormat(false)
+ goHomeAndReturnWithPageLoad()
+
+ // Result of second clock settings change
+ } else {
+ assertThat(
+ "Should use a 12 hour clock.",
+ GeckoAppShell.getIs24HourFormat(),
+ equalTo(false),
+ )
+ }
+ }
+ })
+ }
+ }
+}
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..8ffd4bcbec
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java
@@ -0,0 +1,673 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.assertThat;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+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 org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+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;
+
+@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 (final 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();
+ }
+
+ @Test
+ @UiThreadTest
+ public void testFinallyException() {
+ final GeckoResult<Integer> subject = new GeckoResult<>();
+ final Throwable boom = new Exception("boom");
+
+ subject
+ .map(
+ value -> {
+ assertThat("This should not be called", true, equalTo(false));
+ return null;
+ },
+ error -> {
+ assertThat("Error matches", error, equalTo(boom));
+ return error;
+ })
+ .finally_(() -> done());
+
+ subject.completeExceptionally(boom);
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void testFinallySuccessful() {
+ final GeckoResult<Integer> subject = new GeckoResult<>();
+
+ subject.accept(value -> assertThat("Value matches", value, equalTo(42))).finally_(() -> done());
+
+ subject.complete(42);
+ 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 (final 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 (final 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() {
+ final GeckoResult<Void> result = new GeckoResult<Void>();
+ result
+ .cancel()
+ .accept(
+ value -> {
+ assertThat("Cancellation should fail", value, equalTo(false));
+ done();
+ });
+ waitUntilDone();
+ }
+
+ private GeckoResult<Integer> createCancellableResult() {
+ final 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() {
+ final 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() {
+ final 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() {
+ final GeckoResult<Integer> result = createCancellableResult();
+ final 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() {
+ final 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..41602d9493
--- /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.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.test.util.Environment
+
+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()
+ }
+}
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..d7169c0266
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
@@ -0,0 +1,2114 @@
+/* -*- 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 androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.ContentBlocking.CookieBannerMode
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.GeckoSession.HistoryDelegate
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.GeckoSession.PromptDelegate
+import org.mozilla.geckoview.GeckoSession.ScrollDelegate
+import org.mozilla.geckoview.GeckoSession.SessionState
+import org.mozilla.geckoview.GeckoSessionSettings
+import org.mozilla.geckoview.WebRequestError
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+/**
+ * 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", mainSession, notNullValue())
+ assertThat(
+ "Session is open",
+ mainSession.isOpen,
+ equalTo(true),
+ )
+ }
+
+ @ClosedSessionAtStart
+ @Test
+ fun getSession_closedSession() {
+ assertThat("Session is closed", mainSession.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",
+ mainSession.settings.usePrivateMode,
+ equalTo(true),
+ )
+ assertThat(
+ "DISPLAY_MODE should be set",
+ mainSession.settings.displayMode,
+ equalTo(GeckoSessionSettings.DISPLAY_MODE_MINIMAL_UI),
+ )
+ assertThat(
+ "USE_TRACKING_PROTECTION should be set",
+ mainSession.settings.useTrackingProtection,
+ equalTo(true),
+ )
+ assertThat(
+ "ALLOW_JAVASCRIPT should be set",
+ mainSession.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 : ProgressDelegate, HistoryDelegate {
+ // 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: SessionState) {
+ }
+
+ @AssertCalled(count = 2)
+ override fun onHistoryStateChange(session: GeckoSession, historyList: HistoryDelegate.HistoryList) {
+ }
+ })
+ }
+
+ @NullDelegate.List(
+ NullDelegate(ContentDelegate::class),
+ NullDelegate(NavigationDelegate::class),
+ )
+ @NullDelegate(ScrollDelegate::class)
+ @Test
+ fun nullDelegate() {
+ assertThat(
+ "Content delegate should be null",
+ mainSession.contentDelegate,
+ nullValue(),
+ )
+ assertThat(
+ "Navigation delegate should be null",
+ mainSession.navigationDelegate,
+ nullValue(),
+ )
+ assertThat(
+ "Scroll delegate should be null",
+ mainSession.scrollDelegate,
+ nullValue(),
+ )
+
+ assertThat(
+ "Progress delegate should not be null",
+ mainSession.progressDelegate,
+ notNullValue(),
+ )
+ }
+
+ @NullDelegate(ProgressDelegate::class)
+ @ClosedSessionAtStart
+ @Test
+ fun nullDelegate_closed() {
+ assertThat(
+ "Progress delegate should be null",
+ mainSession.progressDelegate,
+ nullValue(),
+ )
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(ProgressDelegate::class)
+ @ClosedSessionAtStart
+ fun nullDelegate_requireProgressOnOpen() {
+ assertThat(
+ "Progress delegate should be null",
+ mainSession.progressDelegate,
+ nullValue(),
+ )
+
+ mainSession.open()
+ }
+
+ @Test fun waitForPageStop() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun waitForPageStops() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(ProgressDelegate::class)
+ @ClosedSessionAtStart
+ fun waitForPageStops_throwOnNullDelegate() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ mainSession.open(sessionRule.runtime) // Avoid waiting for initial load
+ mainSession.reload()
+ mainSession.waitForPageStops(2)
+ }
+
+ @Test fun waitUntilCalled_anyInterfaceMethod() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(ProgressDelegate::class)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+
+ override fun onSecurityChange(
+ session: GeckoSession,
+ securityInfo: ProgressDelegate.SecurityInformation,
+ ) {
+ counter++
+ }
+
+ override fun onProgressChange(session: GeckoSession, progress: Int) {
+ counter++
+ }
+
+ override fun onSessionStateChange(session: GeckoSession, state: SessionState) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun waitUntilCalled_specificInterfaceMethod() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(
+ ProgressDelegate::class,
+ "onPageStart",
+ "onPageStop",
+ )
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : 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 fun waitUntilCalled_shouldContinue() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : ProgressDelegate, ShouldContinue {
+ var pageStart = false
+
+ override fun shouldContinue(): Boolean = pageStart
+
+ override fun onPageStart(session: GeckoSession, url: String) {
+ pageStart = true
+ }
+
+ // This is here to verify that we don't wait on all methods of this object
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+
+ // This is to verify that the above only waits until pageStart, but not pageStop.
+ // If the above block waits until pageStop, this will time out, indicating a problem.
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ fun waitUntilCalled_throwOnNotGeckoSessionInterface() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(CharSequence::class)
+ }
+
+ fun waitUntilCalled_notThrowOnCallbackInterface() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(ProgressDelegate::class)
+ }
+
+ @NullDelegate(ScrollDelegate::class)
+ @Test
+ fun waitUntilCalled_notThrowOnNonNullDelegateMethod() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ mainSession.reload()
+ mainSession.waitUntilCalled(ProgressDelegate::class, "onPageStop")
+ }
+
+ @Test fun waitUntilCalled_anyObjectMethod() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ counter++
+ }
+
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+
+ override fun onSecurityChange(
+ session: GeckoSession,
+ securityInfo: ProgressDelegate.SecurityInformation,
+ ) {
+ counter++
+ }
+
+ override fun onProgressChange(session: GeckoSession, progress: Int) {
+ counter++
+ }
+
+ override fun onSessionStateChange(session: GeckoSession, state: SessionState) {
+ counter++
+ }
+ })
+
+ assertThat("Callback count should be correct", counter, equalTo(1))
+ }
+
+ @Test fun waitUntilCalled_specificObjectMethod() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : 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(ScrollDelegate::class)
+ fun waitUntilCalled_throwOnNullDelegateObject() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ mainSession.reload()
+ mainSession.waitUntilCalled(object : ScrollDelegate {
+ @AssertCalled
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @NullDelegate(ScrollDelegate::class)
+ @Test
+ fun waitUntilCalled_notThrowOnNonNullDelegateObject() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ mainSession.reload()
+ mainSession.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_multipleCount() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ throw IllegalStateException()
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_zeroCount() {
+ // Support having @AssertCalled(count = 0) annotations for waitUntilCalled calls.
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : ProgressDelegate, 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : ScrollDelegate {})
+ }
+
+ @Test fun forCallbacksDuringWait_specificMethod() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : ScrollDelegate {
+ @AssertCalled
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_specificCount() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+
+ var counter = 0
+
+ sessionRule.waitUntilCalled(object : 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 forCallbacksDuringWait_specificCount() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : 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 waitUntilCalled_throwOnWrongCount() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(count = 2)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnWrongCount() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_specificOrder() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled(order = [1])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(order = [2])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_specificOrder() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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 waitUntilCalled_throwOnWrongOrder() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled(order = [2])
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(order = [1])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnWrongOrder() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : ScrollDelegate {
+ @AssertCalled(false)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun waitUntilCalled_throwOnCallingZeroCall() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled(count = 0)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ fun waitUntilCalled_assertCalledFalseNoTimeout() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : ProgressDelegate, NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+
+ @AssertCalled(false)
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ return null
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun waitUntilCalled_throwOnCallingNoCall() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {}
+
+ @AssertCalled(false)
+ override fun onPageStart(session: GeckoSession, url: String) {}
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnCallingNoCall() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_zeroCountEqualsNotCalled() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : ScrollDelegate {
+ @AssertCalled(count = 0)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun forCallbacksDuringWait_throwOnCallingZeroCount() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : ProgressDelegate {
+ @AssertCalled(count = 0)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun forCallbacksDuringWait_limitedToLastWait() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+ mainSession.reload()
+ mainSession.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 : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ throw IllegalStateException()
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(ScrollDelegate::class)
+ fun forCallbacksDuringWait_throwOnAnyNullDelegate() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ mainSession.forCallbacksDuringWait(object : NavigationDelegate, ScrollDelegate {})
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(ScrollDelegate::class)
+ fun forCallbacksDuringWait_throwOnSpecificNullDelegate() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ mainSession.forCallbacksDuringWait(object : ScrollDelegate {
+ @AssertCalled
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @NullDelegate(ScrollDelegate::class)
+ @Test
+ fun forCallbacksDuringWait_notThrowOnNonNullDelegate() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ mainSession.forCallbacksDuringWait(object : ProgressDelegate {
+ @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 : 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++
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @Test fun delegateUntilTestEnd_notCalled() {
+ sessionRule.delegateUntilTestEnd(object : ScrollDelegate {
+ @AssertCalled(false)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateUntilTestEnd_throwOnNotCalled() {
+ sessionRule.delegateUntilTestEnd(object : 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 : ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateUntilTestEnd_throwOnWrongOrder() {
+ sessionRule.delegateUntilTestEnd(object : 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) {
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test fun delegateUntilTestEnd_currentCall() {
+ sessionRule.delegateUntilTestEnd(object : 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),
+ )
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test fun delegateDuringNextWait() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ var counter = 0
+
+ sessionRule.delegateDuringNextWait(object : 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++
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat("Should have delegated", counter, equalTo(2))
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ assertThat("Delegate should be cleared", counter, equalTo(2))
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateDuringNextWait_throwOnNotCalled() {
+ sessionRule.delegateDuringNextWait(object : ScrollDelegate {
+ @AssertCalled(count = 1)
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ })
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ fun delegateDuringNextWait_throwOnNotCalledAtTestEnd() {
+ sessionRule.delegateDuringNextWait(object : 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 :
+ ProgressDelegate,
+ 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 : 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++
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat(
+ "Text delegate should be overridden",
+ testCounter,
+ equalTo(2),
+ )
+ assertThat("Wait delegate should be used", waitCounter, equalTo(2))
+
+ mainSession.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 : ProgressDelegate {
+ @AssertCalled
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ throw IllegalStateException()
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ @Test(expected = AssertionError::class)
+ @NullDelegate(NavigationDelegate::class)
+ fun delegateDuringNextWait_throwOnNullDelegate() {
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ }
+ })
+ }
+
+ @Test fun wrapSession() {
+ val session = sessionRule.wrapSession(
+ GeckoSession(mainSession.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(mainSession.settings),
+ )
+ }
+
+ @Test fun createOpenSession_withSettings() {
+ val settings = GeckoSessionSettings.Builder(mainSession.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))
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ val newSession = sessionRule.createOpenSession()
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ newSession.forCallbacksDuringWait(object : ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+
+ mainSession.forCallbacksDuringWait(object : 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(mainSession.settings),
+ )
+ }
+
+ @Test fun createClosedSession_withSettings() {
+ val settings = GeckoSessionSettings.Builder(mainSession.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 : HistoryDelegate, ProgressDelegate {
+ // 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: SessionState) {
+ }
+
+ @AssertCalled(count = 2)
+ override fun onHistoryStateChange(session: GeckoSession, historyList: 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(mainSession.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)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+ }
+
+ @Test fun waitForPageStops_acrossSessionCreation() {
+ // TODO: Bug 1673953
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ val session = sessionRule.createOpenSession()
+ mainSession.reload()
+ session.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(3)
+ }
+
+ @Test fun waitUntilCalled_interfaceWithSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitUntilCalled(ProgressDelegate::class, "onPageStop")
+ }
+
+ @Test fun waitUntilCalled_interfaceWithAllSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(ProgressDelegate::class, "onPageStop")
+ }
+
+ @Test fun waitUntilCalled_callbackWithSpecificSession() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+ })
+ }
+
+ @Test fun waitUntilCalled_callbackWithAllSessions() {
+ val newSession = sessionRule.createOpenSession()
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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 : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ mainSession.forCallbacksDuringWait(object : 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)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ var counter = 0
+
+ sessionRule.forCallbacksDuringWait(object : 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()
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ newSession.waitForPageStop()
+
+ // forCallbacksDuringWait calls strictly apply to the last wait, session-specific or not.
+ var counter = 0
+
+ mainSession.forCallbacksDuringWait(object : ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.forCallbacksDuringWait(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.forCallbacksDuringWait(object : 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 : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ mainSession.delegateUntilTestEnd(object : 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 : 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 : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.delegateUntilTestEnd(object : ProgressDelegate {
+ @AssertCalled(false)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.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 : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ counter++
+ }
+ })
+
+ newSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ assertThat("Callback count should be correct", counter, equalTo(2))
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun synthesizeTap() {
+ mainSession.loadTestPath(CLICK_TO_RELOAD_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.synthesizeTap(50, 50)
+ mainSession.waitForPageStop()
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun synthesizeMouseMove() {
+ mainSession.loadTestPath(MOUSE_TO_RELOAD_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.synthesizeMouseMove(50, 50)
+ mainSession.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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "JS string result should be correct",
+ mainSession.evaluateJS("'foo'") as String,
+ equalTo("foo"),
+ )
+
+ assertThat(
+ "JS number result should be correct",
+ mainSession.evaluateJS("1+1") as Double,
+ equalTo(2.0),
+ )
+
+ assertThat(
+ "JS boolean result should be correct",
+ mainSession.evaluateJS("!0") as Boolean,
+ equalTo(true),
+ )
+
+ val expected = JSONObject("{bar:42,baz:true,foo:'bar'}")
+ val actual = mainSession.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",
+ mainSession.evaluateJS("[1,2,3]") as JSONArray,
+ equalTo(JSONArray("[1,2,3]")),
+ )
+
+ assertThat(
+ "JS DOM object result should be correct",
+ mainSession.evaluateJS("document.body.tagName") as String,
+ equalTo("BODY"),
+ )
+ }
+
+ @Test fun evaluateJS_windowObject() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "JS DOM window result should be correct",
+ (mainSession.evaluateJS("window.location.pathname")) as String,
+ equalTo(HELLO_HTML_PATH),
+ )
+ }
+
+ @Test fun evaluateJS_multipleSessions() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("this.foo = 42")
+ assertThat(
+ "Variable should be set",
+ mainSession.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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "Can get resolved promise",
+ mainSession.evaluatePromiseJS(
+ "new Promise(resolve => resolve('foo'))",
+ ).value as String,
+ equalTo("foo"),
+ )
+
+ val promise = mainSession.evaluatePromiseJS(
+ "new Promise(r => window.resolve = r)",
+ )
+
+ mainSession.evaluateJS("window.resolve('bar')")
+
+ assertThat(
+ "Can wait for promise to resolve",
+ promise.value as String,
+ equalTo("bar"),
+ )
+ }
+
+ @Test(expected = RejectedPromiseException::class)
+ fun evaluateJS_throwOnRejectedPromise() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluatePromiseJS("Promise.reject('foo')").value
+ }
+
+ @Test fun evaluateJS_notBlockMainThread() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.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",
+ mainSession.evaluateJS("alert(); 'foo'") as String,
+ equalTo("foo"),
+ )
+ }
+
+ @TimeoutMillis(1000)
+ @Test(expected = UiThreadUtils.TimeoutException::class)
+ fun evaluateJS_canTimeout() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ // Return a GeckoResult that we will never complete, so it hangs.
+ val res = GeckoResult<PromptDelegate.PromptResponse>()
+ return res
+ }
+ })
+ mainSession.evaluateJS("new Promise(resolve => window.setTimeout(resolve, 2000))")
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun evaluateJS_throwOnJSException() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("throw Error()")
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun evaluateJS_throwOnSyntaxError() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("<{[")
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun evaluateJS_throwOnChromeAccess() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.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() {
+ mainSession.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"))
+
+ mainSession.reload()
+ mainSession.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() {
+ mainSession.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"))
+
+ mainSession.reload()
+ mainSession.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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ assertThat(
+ "waitForJS should return correct result",
+ mainSession.waitForJS("alert(), 'foo'") as String,
+ equalTo("foo"),
+ )
+
+ mainSession.forCallbacksDuringWait(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun waitForJS_resolvePromise() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ assertThat(
+ "waitForJS should wait for promises",
+ mainSession.waitForJS("Promise.resolve('foo')") as String,
+ equalTo("foo"),
+ )
+ }
+
+ @Test fun waitForJS_delegateDuringWait() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ var count = 0
+ mainSession.delegateDuringNextWait(object : PromptDelegate {
+ override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ count++
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ mainSession.waitForJS("alert()")
+ mainSession.waitForJS("alert()")
+
+ // The delegate set through delegateDuringNextWait
+ // should have been cleared after the first wait.
+ assertThat("Delegate should only run once", count, equalTo(1))
+ }
+
+ @Test(expected = RejectedPromiseException::class)
+ fun waitForJS_whileNavigating() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // Trigger navigation and try again
+ mainSession.loadTestPath(HELLO2_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // Navigate away and trigger a waitForJS that never completes, this will
+ // fail because the page navigates away (disconnecting the port) before
+ // the page can respond.
+ mainSession.goBack()
+ mainSession.waitForJS("new Promise(resolve => {})")
+ }
+
+ 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() {
+ mainSession.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) {}) // ktlint-disable annotation
+
+ assertThat("Delegate should be unregistered after wait", delegate, nullValue())
+ }
+
+ @Test fun addExternalDelegateDuringNextWait_hasPrecedence() {
+ mainSession.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))
+
+ // TODO: bug 1710940
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ mainSession.loadUri(CONTENT_CRASH_URL)
+ mainSession.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onCrash(session: GeckoSession) = Unit
+ })
+ }
+
+ @Test(expected = ChildCrashedException::class)
+ fun contentCrashFails() {
+ assumeThat(sessionRule.env.shouldShutdownOnCrash(), equalTo(false))
+
+ mainSession.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)
+ }
+
+ @Test fun checkCookieBannerRuleForSession() {
+ // set preferences. We have a cookie rule for example.com
+ val testRules = "[{\"id\":\"87815b2d-a840-4155-8713-f8a26d1f483a\",\"click\":{\"optOut\":\"#optOutBtn\",\"presence\": \"#cookieBanner\"},\"cookies\":{\"optOut\":[{\"name\":\"foo\", \"value\":\"bar\"}]}, \"domains\":[\"example.org\"]}]"
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT,
+ "cookiebanners.listService.testSkipRemoteSettings" to true,
+ "cookiebanners.listService.testRules" to testRules,
+ "cookiebanners.service.detectOnly" to false,
+ ),
+ )
+ var prefs = sessionRule.getPrefs(
+ "cookiebanners.service.mode",
+ "cookiebanners.listService.testSkipRemoteSettings",
+ "cookiebanners.listService.testRules",
+ "cookiebanners.service.detectOnly",
+ )
+ assertThat("Cookie banner service mode should be correct", prefs[0] as Int, equalTo(1))
+ assertThat("Cookie banner remote settings should be skipped", prefs[1] as Boolean, equalTo(true))
+ assertThat("Cookie banner rule should be set", prefs[2] as String, equalTo(testRules))
+ assertThat("Cookie banner service should not be in detect only mode", prefs[3] as Boolean, equalTo(false))
+
+ // session 1 - load url for which there is no rule
+ mainSession.loadUri(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ val response1 = mainSession.hasCookieBannerRuleForBrowsingContextTree()
+ sessionRule.waitForResult(response1).let {
+ assertThat("There should be no rule", it, equalTo(false))
+ }
+
+ // session 1 - load url for which there is a rule
+ mainSession.loadUri("http://example.org/")
+ sessionRule.waitForPageStop()
+ val response2 = mainSession.hasCookieBannerRuleForBrowsingContextTree()
+ sessionRule.waitForResult(response2).let {
+ assertThat("There should be a rule", it, equalTo(true))
+ }
+
+ // session 2 load url for which there is no rule
+ val session2 = sessionRule.createOpenSession()
+ session2.loadUri(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ val response3 = session2.hasCookieBannerRuleForBrowsingContextTree()
+ sessionRule.waitForResult(response3).let {
+ assertThat("There should be no rule", it, equalTo(false))
+ }
+
+ // API shoul return the correct result for the page we have loaded in session 1
+ val response4 = mainSession.hasCookieBannerRuleForBrowsingContextTree()
+ sessionRule.waitForResult(response4).let {
+ assertThat("There should be a rule the second time", it, equalTo(true))
+ }
+ }
+}
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..82af2c6475
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt
@@ -0,0 +1,462 @@
+package org.mozilla.geckoview.test
+
+import android.content.Context
+import android.graphics.Matrix
+import android.os.Build
+import android.os.Bundle
+import android.os.LocaleList
+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 androidx.core.view.ViewCompat
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import org.hamcrest.Matchers.equalTo
+import org.junit.* // ktlint-disable no-wildcard-imports
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeThat
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.Autofill
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoView
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+import org.mozilla.geckoview.test.util.UiThreadUtils
+import java.io.File
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class GeckoViewTest : BaseSessionTest() {
+ val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+
+ @get:Rule
+ override val rules = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ @Before
+ fun setup() {
+ activityRule.scenario.onActivity {
+ // Attach the default session from the session rule to the GeckoView
+ it.view.setSession(sessionRule.session)
+ }
+ }
+
+ @After
+ fun cleanup() {
+ activityRule.scenario.onActivity {
+ it.view.releaseSession()
+ }
+ }
+
+ @Test
+ fun setSessionOnClosed() {
+ activityRule.scenario.onActivity {
+ it.view.session!!.close()
+ it.view.setSession(GeckoSession())
+ }
+ }
+
+ @Test
+ fun setSessionOnOpenDoesNotThrow() {
+ activityRule.scenario.onActivity {
+ assertThat("Session is open", it.view.session!!.isOpen, equalTo(true))
+ val newSession = GeckoSession()
+ it.view.setSession(newSession)
+ assertThat(
+ "The new session should be correctly set.",
+ it.view.session,
+ equalTo(newSession),
+ )
+ }
+ }
+
+ @Test(expected = java.lang.IllegalStateException::class)
+ fun displayAlreadyAcquired() {
+ activityRule.scenario.onActivity {
+ assertThat(
+ "View should be attached",
+ ViewCompat.isAttachedToWindow(it.view),
+ equalTo(true),
+ )
+ it.view.session!!.acquireDisplay()
+ }
+ }
+
+ @Test
+ fun relaseOnDetach() {
+ activityRule.scenario.onActivity {
+ // The GeckoDisplay should be released when the View is detached from the window...
+ it.view.onDetachedFromWindow()
+ it.view.session!!.releaseDisplay(it.view.session!!.acquireDisplay())
+ }
+ }
+
+ private fun waitUntilContentProcessPriority(high: List<GeckoSession>, low: List<GeckoSession>) {
+ val highPids = high.map { sessionRule.getSessionPid(it) }.toSet()
+ val lowPids = low.map { sessionRule.getSessionPid(it) }.toSet()
+
+ UiThreadUtils.waitForCondition({
+ val shouldBeHighPri = getContentProcessesOomScore(highPids)
+ val shouldBeLowPri = getContentProcessesOomScore(lowPids)
+ // Note that higher oom score means less priority
+ shouldBeHighPri.count { it > 100 } == 0 &&
+ shouldBeLowPri.count { it < 300 } == 0
+ }, env.defaultTimeoutMillis)
+ }
+
+ fun getContentProcessesOomScore(pids: Collection<Int>): List<Int> {
+ return pids.map { pid ->
+ File("/proc/$pid/oom_score").readText(Charsets.UTF_8).trim().toInt()
+ }
+ }
+
+ fun setupPriorityTest(): GeckoSession {
+ // This makes the test a little bit faster
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "dom.ipc.processPriorityManager.backgroundGracePeriodMS" to 0,
+ "dom.ipc.processPriorityManager.backgroundPerceivableGracePeriodMS" to 0,
+ ),
+ )
+
+ val otherSession = sessionRule.createOpenSession()
+ // The process manager sets newly created processes to FOREGROUND priority until they
+ // are de-prioritized, so we need to activate and deactivate the session to trigger
+ // a setPriority call.
+ otherSession.setActive(true)
+ otherSession.setActive(false)
+
+ // Need a dummy page to be able to get the PID from the session
+ otherSession.loadUri("https://example.com")
+ otherSession.waitForPageStop()
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ waitUntilContentProcessPriority(
+ high = listOf(mainSession),
+ low = listOf(otherSession),
+ )
+
+ return otherSession
+ }
+
+ @Test
+ @NullDelegate(Autofill.Delegate::class)
+ fun setTabActiveKeepsTabAtHighPriority() {
+ // Bug 1768102 - Doesn't seem to work on Fission
+ assumeThat(env.isFission || env.isIsolatedProcess, equalTo(false))
+ activityRule.scenario.onActivity {
+ val otherSession = setupPriorityTest()
+
+ // A tab with priority hint does not get de-prioritized even when
+ // the surface is destroyed
+ mainSession.setPriorityHint(GeckoSession.PRIORITY_HIGH)
+
+ // This will destroy mainSession's surface and create a surface for otherSession
+ it.view.setSession(otherSession)
+
+ waitUntilContentProcessPriority(high = listOf(mainSession, otherSession), low = listOf())
+
+ // Destroying otherSession's surface should leave mainSession as the sole high priority
+ // tab
+ it.view.releaseSession()
+
+ waitUntilContentProcessPriority(high = listOf(mainSession), low = listOf())
+
+ // Cleanup
+ mainSession.setPriorityHint(GeckoSession.PRIORITY_DEFAULT)
+ }
+ }
+
+ @Test
+ @NullDelegate(Autofill.Delegate::class)
+ fun processPriorityTest() {
+ // Doesn't seem to work on Fission
+ assumeThat(env.isFission || env.isIsolatedProcess, equalTo(false))
+ activityRule.scenario.onActivity {
+ val otherSession = setupPriorityTest()
+
+ // After setting otherSession to the view, otherSession should be high priority
+ // and mainSession should be de-prioritized
+ it.view.setSession(otherSession)
+
+ waitUntilContentProcessPriority(
+ high = listOf(otherSession),
+ low = listOf(mainSession),
+ )
+
+ // After releasing otherSession, both sessions should be low priority
+ it.view.releaseSession()
+
+ waitUntilContentProcessPriority(
+ high = listOf(),
+ low = listOf(mainSession, otherSession),
+ )
+
+ // Test that re-setting mainSession in the view raises the priority again
+ it.view.setSession(mainSession)
+ waitUntilContentProcessPriority(
+ high = listOf(mainSession),
+ low = listOf(otherSession),
+ )
+
+ // Setting the session to active should also raise priority
+ otherSession.setActive(true)
+ waitUntilContentProcessPriority(
+ high = listOf(mainSession, otherSession),
+ low = listOf(),
+ )
+ }
+ }
+
+ @Test
+ @NullDelegate(Autofill.Delegate::class)
+ fun setPriorityHint() {
+ // Bug 1768102 - Doesn't seem to work on Fission
+ assumeThat(env.isFission || env.isIsolatedProcess, equalTo(false))
+
+ val otherSession = setupPriorityTest()
+
+ // Setting priorityHint to PRIORITY_HIGH raises priority
+ otherSession.setPriorityHint(GeckoSession.PRIORITY_HIGH)
+
+ waitUntilContentProcessPriority(
+ high = listOf(mainSession, otherSession),
+ low = listOf(),
+ )
+
+ // Setting priorityHint to PRIORITY_DEFAULT should lower priority
+ otherSession.setPriorityHint(GeckoSession.PRIORITY_DEFAULT)
+
+ waitUntilContentProcessPriority(
+ high = listOf(mainSession),
+ low = listOf(otherSession),
+ )
+ }
+
+ private fun visit(node: MockViewStructure, callback: (MockViewStructure) -> Unit) {
+ callback(node)
+
+ for (child in node.children) {
+ if (child != null) {
+ visit(child, callback)
+ }
+ }
+ }
+
+ @Test
+ @NullDelegate(Autofill.Delegate::class)
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ fun autofillWithNoSession() {
+ mainSession.loadTestPath(FORMS_XORIGIN_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val autofills = mapOf(
+ "#user1" to "username@example.com",
+ "#user2" to "username@example.com",
+ "#pass1" to "test-password",
+ "#pass2" to "test-password",
+ )
+
+ // Set up promises to monitor the values changing.
+ val promises = autofills.map { entry ->
+ // Repeat each test with both the top document and the iframe document.
+ mainSession.evaluatePromiseJS(
+ """
+ window.getDataForAllFrames('${entry.key}', '${entry.value}')
+ """,
+ )
+ }
+
+ activityRule.scenario.onActivity {
+ val root = MockViewStructure(View.NO_ID)
+ it.view.onProvideAutofillVirtualStructure(root, 0)
+
+ val data = SparseArray<AutofillValue>()
+ visit(root) { node ->
+ if (node.hints?.indexOf(View.AUTOFILL_HINT_USERNAME) != -1) {
+ data.set(node.id, AutofillValue.forText("username@example.com"))
+ } else if (node.hints?.indexOf(View.AUTOFILL_HINT_PASSWORD) != -1) {
+ data.set(node.id, AutofillValue.forText("test-password"))
+ }
+ }
+
+ // Releasing the session will set mSession in GeckoView to null
+ // this test verifies that we can still autofill correctly even in released state
+ val session = it.view.releaseSession()!!
+ it.view.autofill(data)
+
+ // Put back the session and verifies that the autofill went through anyway
+ it.view.setSession(session)
+
+ // Wait on the promises and check for correct values.
+ for (values in promises.map { p -> p.value.asJsonArray() }) {
+ for (i in 0 until values.length()) {
+ val (key, actual, expected, eventInterface) = values.get(i).asJSList<String>()
+
+ assertThat("Auto-filled value must match ($key)", actual, equalTo(expected))
+ assertThat(
+ "input event should be dispatched with InputEvent interface",
+ eventInterface,
+ equalTo("InputEvent"),
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ @NullDelegate(Autofill.Delegate::class)
+ fun activityContextDelegate() {
+ var delegateCalled = false
+ activityRule.scenario.onActivity {
+ class TestActivityDelegate : GeckoView.ActivityContextDelegate {
+ override fun getActivityContext(): Context {
+ delegateCalled = true
+ return it
+ }
+ }
+ // Set view delegate
+ it.view.activityContextDelegate = TestActivityDelegate()
+ val context = it.view.activityContextDelegate?.activityContext
+ assertTrue("The activity context delegate was called.", delegateCalled)
+ assertTrue("The activity context delegate provided the expected context.", context == it)
+ }
+ }
+
+ class MockViewStructure(var id: Int, var parent: MockViewStructure? = null) : ViewStructure() {
+ private var enabled: Boolean = false
+ private var inputType = 0
+ var children = Array<MockViewStructure?>(0, { null })
+ var childIndex = 0
+ var hints: Array<out String>? = null
+
+ override fun setId(p0: Int, p1: String?, p2: String?, p3: String?) {
+ id = p0
+ }
+
+ override fun setEnabled(p0: Boolean) {
+ enabled = p0
+ }
+
+ override fun setChildCount(p0: Int) {
+ children = Array(p0, { null })
+ }
+
+ override fun getChildCount(): Int {
+ return children.size
+ }
+
+ override fun newChild(p0: Int): ViewStructure {
+ val child = MockViewStructure(p0, this)
+ children[childIndex++] = child
+ return child
+ }
+
+ override fun asyncNewChild(p0: Int): ViewStructure {
+ return newChild(p0)
+ }
+
+ override fun setInputType(p0: Int) {
+ inputType = p0
+ }
+
+ fun getInputType(): Int {
+ return inputType
+ }
+
+ override fun setAutofillHints(p0: Array<out String>?) {
+ hints = p0
+ }
+
+ override fun addChildCount(p0: Int): Int {
+ TODO()
+ }
+
+ override fun setDimens(p0: Int, p1: Int, p2: Int, p3: Int, p4: Int, p5: Int) {}
+ override fun setTransformation(p0: Matrix?) {}
+ override fun setElevation(p0: Float) {}
+ override fun setAlpha(p0: Float) {}
+ override fun setVisibility(p0: Int) {}
+ override fun setClickable(p0: Boolean) {}
+ override fun setLongClickable(p0: Boolean) {}
+ override fun setContextClickable(p0: Boolean) {}
+ override fun setFocusable(p0: Boolean) {}
+ override fun setFocused(p0: Boolean) {}
+ override fun setAccessibilityFocused(p0: Boolean) {}
+ override fun setCheckable(p0: Boolean) {}
+ override fun setChecked(p0: Boolean) {}
+ override fun setSelected(p0: Boolean) {}
+ override fun setActivated(p0: Boolean) {}
+ override fun setOpaque(p0: Boolean) {}
+ override fun setClassName(p0: String?) {}
+ override fun setContentDescription(p0: CharSequence?) {}
+ override fun setText(p0: CharSequence?) {}
+ override fun setText(p0: CharSequence?, p1: Int, p2: Int) {}
+ override fun setTextStyle(p0: Float, p1: Int, p2: Int, p3: Int) {}
+ override fun setTextLines(p0: IntArray?, p1: IntArray?) {}
+ override fun setHint(p0: CharSequence?) {}
+ override fun getText(): CharSequence {
+ return ""
+ }
+ override fun getTextSelectionStart(): Int {
+ return 0
+ }
+ override fun getTextSelectionEnd(): Int {
+ return 0
+ }
+ override fun getHint(): CharSequence {
+ return ""
+ }
+ override fun getExtras(): Bundle {
+ return Bundle()
+ }
+ override fun hasExtras(): Boolean {
+ return false
+ }
+
+ override fun getAutofillId(): AutofillId? {
+ return null
+ }
+ override fun setAutofillId(p0: AutofillId) {}
+ override fun setAutofillId(p0: AutofillId, p1: Int) {}
+ override fun setAutofillType(p0: Int) {}
+ override fun setAutofillValue(p0: AutofillValue?) {}
+ override fun setAutofillOptions(p0: Array<out CharSequence>?) {}
+ override fun setDataIsSensitive(p0: Boolean) {}
+ override fun asyncCommit() {}
+ override fun setWebDomain(p0: String?) {}
+ override fun setLocaleList(p0: LocaleList?) {}
+
+ override fun newHtmlInfoBuilder(p0: String): HtmlInfo.Builder {
+ return MockHtmlInfoBuilder()
+ }
+ override fun setHtmlInfo(p0: HtmlInfo) {
+ }
+ }
+
+ class MockHtmlInfoBuilder : ViewStructure.HtmlInfo.Builder() {
+ override fun addAttribute(p0: String, p1: String): ViewStructure.HtmlInfo.Builder {
+ return this
+ }
+
+ override fun build(): ViewStructure.HtmlInfo {
+ return MockHtmlInfo()
+ }
+ }
+
+ class MockHtmlInfo : ViewStructure.HtmlInfo() {
+ override fun getTag(): String {
+ TODO("Not yet implemented")
+ }
+
+ override fun getAttributes(): MutableList<Pair<String, String>>? {
+ TODO("Not yet implemented")
+ }
+ }
+}
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..bc1ffb14b9
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test;
+
+import android.app.Activity;
+import android.content.ContextWrapper;
+import android.os.Bundle;
+import org.mozilla.geckoview.GeckoView;
+
+public class GeckoViewTestActivity extends Activity {
+ public GeckoView view;
+
+ @Override
+ protected void onCreate(final 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/GeolocationTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt
new file mode 100644
index 0000000000..4c51a4d65c
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt
@@ -0,0 +1,294 @@
+/* -*- 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.content.Context
+import android.location.LocationManager
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.lifecycle.* // ktlint-disable no-wildcard-imports
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.core.IsNot.not
+import org.json.JSONObject
+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.Autofill
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.MockLocationProvider
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class GeolocationTest : BaseSessionTest() {
+ private val LOGTAG = "GeolocationTest"
+ private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+ private lateinit var locManager: LocationManager
+ private lateinit var mockGpsProvider: MockLocationProvider
+ private lateinit var mockNetworkProvider: MockLocationProvider
+
+ @get:Rule
+ override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ @Before
+ fun setup() {
+ activityRule.scenario.onActivity { activity ->
+ activity.view.setSession(mainSession)
+ // Prevents using the network provider for these tests
+ sessionRule.setPrefsUntilTestEnd(mapOf("geo.provider.testing" to false))
+ locManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+ mockGpsProvider = sessionRule.MockLocationProvider(locManager, LocationManager.GPS_PROVIDER, 0.0, 0.0, true)
+ mockNetworkProvider = sessionRule.MockLocationProvider(locManager, LocationManager.NETWORK_PROVIDER, 0.0, 0.0, true)
+ }
+ }
+
+ @After
+ fun cleanup() {
+ try {
+ activityRule.scenario.onActivity { activity ->
+ activity.view.releaseSession()
+ }
+ mockGpsProvider.removeMockLocationProvider()
+ mockNetworkProvider.removeMockLocationProvider()
+ } catch (e: Exception) {}
+ }
+
+ private fun setEnableLocationPermissions() {
+ sessionRule.delegateDuringNextWait(object : GeckoSession.PermissionDelegate {
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: GeckoSession.PermissionDelegate.ContentPermission,
+ ):
+ GeckoResult<Int> {
+ return GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW)
+ }
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession,
+ permissions: Array<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback,
+ ) {
+ callback.grant()
+ }
+ })
+ }
+
+ private fun getCurrentPositionJS(maximumAge: Number = 0, timeout: Number = 3000, enableHighAccuracy: Boolean = false): JSONObject {
+ return mainSession.evaluatePromiseJS(
+ """
+ new Promise((resolve, reject) =>
+ window.navigator.geolocation.getCurrentPosition(
+ position => resolve(
+ {latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ accuracy: position.coords.accuracy}),
+ error => reject(error.code),
+ {maximumAge: $maximumAge,
+ timeout: $timeout,
+ enableHighAccuracy: $enableHighAccuracy }))""",
+ ).value as JSONObject
+ }
+
+ private fun getCurrentPositionJSWithWait(): JSONObject {
+ return mainSession.evaluatePromiseJS(
+ """
+ new Promise((resolve, reject) =>
+ setTimeout(() => {
+ window.navigator.geolocation.getCurrentPosition(
+ position => resolve(
+ {latitude: position.coords.latitude, longitude: position.coords.longitude})),
+ error => reject(error.code)
+ }, "750"))""",
+ ).value as JSONObject
+ }
+
+ @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class)
+ // General test that location can be requested from JS and that the mock provider is providing location
+ @Test
+ fun jsContentRequestForLocation() {
+ val mockLat = 1.1111
+ val mockLon = 2.2222
+ mockGpsProvider.setMockLocation(mockLat, mockLon)
+ mockGpsProvider.setDoContinuallyPost(true)
+ mockGpsProvider.postLocation()
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ setEnableLocationPermissions()
+
+ val position = getCurrentPositionJS()
+ mockGpsProvider.stopPostingLocation()
+ assertThat("Mocked latitude matches.", position["latitude"] as Number, equalTo(mockLat))
+ assertThat("Mocked longitude matches.", position["longitude"] as Number, equalTo(mockLon))
+ }
+
+ @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class)
+ // Testing that more accurate location providers are selected without high accuracy enabled
+ @Test
+ fun accurateProviderSelected() {
+ val highAccuracy = .000001f
+ val highMockLat = 1.1111
+ val highMockLon = 2.2222
+
+ // Lower accuracy should still be better than device provider ~20m
+ val lowAccuracy = 10.01f
+ val lowMockLat = 3.3333
+ val lowMockLon = 4.4444
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ setEnableLocationPermissions()
+
+ // Test when lower accuracy is more recent
+ mockGpsProvider.setMockLocation(highMockLat, highMockLon, highAccuracy)
+ mockGpsProvider.setDoContinuallyPost(false)
+ mockGpsProvider.postLocation()
+
+ // Sleep ensures the mocked locations have different clock times
+ Thread.sleep(10)
+ // Set inaccurate second, so that it is the most recent location
+ mockNetworkProvider.setMockLocation(lowMockLat, lowMockLon, lowAccuracy)
+ mockNetworkProvider.setDoContinuallyPost(false)
+ mockNetworkProvider.postLocation()
+
+ val position = getCurrentPositionJS(0, 3000, false)
+ assertThat("Higher accuracy latitude is expected.", position["latitude"] as Number, equalTo(highMockLat))
+ assertThat("Higher accuracy longitude is expected.", position["longitude"] as Number, equalTo(highMockLon))
+
+ // Test that higher accuracy becomes stale after 6 seconds
+ mockGpsProvider.postLocation()
+ Thread.sleep(6001)
+ mockNetworkProvider.postLocation()
+ val inaccuratePosition = getCurrentPositionJS(0, 3000, false)
+ assertThat("Lower accuracy latitude is expected.", inaccuratePosition["latitude"] as Number, equalTo(lowMockLat))
+ assertThat("Lower accuracy longitude is expected.", inaccuratePosition["longitude"] as Number, equalTo(lowMockLon))
+ }
+
+ @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class)
+ // Testing that high accuracy requests a fresh location
+ @Test
+ fun highAccuracyTest() {
+ val accuracyMed = 4f
+ val accuracyHigh = .000001f
+ val latMedAcc = 1.1111
+ val lonMedAcc = 2.2222
+ val latHighAcc = 3.3333
+ val lonHighAcc = 4.4444
+
+ // High accuracy usage requires HTTPS
+ mainSession.loadUri("https://example.com/")
+ mainSession.waitForPageStop()
+ setEnableLocationPermissions()
+
+ // Have two location providers posting locations
+ mockNetworkProvider.setMockLocation(latMedAcc, lonMedAcc, accuracyMed)
+ mockNetworkProvider.setDoContinuallyPost(true)
+ mockNetworkProvider.postLocation()
+
+ mockGpsProvider.setMockLocation(latHighAcc, lonHighAcc, accuracyHigh)
+ mockGpsProvider.setDoContinuallyPost(true)
+ mockGpsProvider.postLocation()
+
+ val highAccuracyPosition = getCurrentPositionJS(0, 6001, true)
+ mockGpsProvider.stopPostingLocation()
+ mockNetworkProvider.stopPostingLocation()
+
+ assertThat("High accuracy latitude is expected.", highAccuracyPosition["latitude"] as Number, equalTo(latHighAcc))
+ assertThat("High accuracy longitude is expected.", highAccuracyPosition["longitude"] as Number, equalTo(lonHighAcc))
+ }
+
+ @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class)
+ // Checks that location services is reenabled after going to background
+ @Test
+ fun locationOnBackground() {
+ val beforePauseLat = 1.1111
+ val beforePauseLon = 2.2222
+ val afterPauseLat = 3.3333
+ val afterPauseLon = 4.4444
+ mockGpsProvider.setDoContinuallyPost(true)
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+ setEnableLocationPermissions()
+
+ var actualResumeCount = 0
+ var actualPauseCount = 0
+
+ // Monitor lifecycle changes
+ ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onResume(owner: LifecycleOwner) {
+ Log.i(LOGTAG, "onResume Event")
+ actualResumeCount++
+ super.onResume(owner)
+ try {
+ mainSession.setActive(true)
+ // onResume is also called when starting too
+ if (actualResumeCount > 1) {
+ // Ensures the location has had time to post
+ Thread.sleep(3001)
+ val onResumeFromPausePosition = getCurrentPositionJS()
+ assertThat("Latitude after onPause matches.", onResumeFromPausePosition["latitude"] as Number, equalTo(afterPauseLat))
+ assertThat("Longitude after onPause matches.", onResumeFromPausePosition["longitude"] as Number, equalTo(afterPauseLon))
+ }
+ } catch (e: Exception) {
+ // Intermittent CI test issue where Activity is gone after resume occurs
+ assertThat("onResume count matches.", actualResumeCount, equalTo(2))
+ assertThat("onPause count matches.", actualPauseCount, equalTo(1))
+ try {
+ mockGpsProvider.removeMockLocationProvider()
+ } catch (e: Exception) {
+ // Cleanup could have already occurred
+ }
+ }
+ }
+ override fun onPause(owner: LifecycleOwner) {
+ Log.i(LOGTAG, "onPause Event")
+ actualPauseCount++
+ super.onPause(owner)
+ try {
+ mockGpsProvider.setMockLocation(afterPauseLat, afterPauseLon)
+ mockGpsProvider.postLocation()
+ } catch (e: Exception) {
+ Log.w(LOGTAG, "onPause was called too late.")
+ // Potential situation where onPause is called too late
+ }
+ }
+ })
+
+ // Before onPause Event
+ mockGpsProvider.setMockLocation(beforePauseLat, beforePauseLon)
+ mockGpsProvider.postLocation()
+ val beforeOnPausePosition = getCurrentPositionJS()
+ assertThat("Latitude before onPause matches.", beforeOnPausePosition["latitude"] as Number, equalTo(beforePauseLat))
+ assertThat("Longitude before onPause matches.", beforeOnPausePosition["longitude"] as Number, equalTo(beforePauseLon))
+
+ // Ensures a return to the foreground
+ Handler(Looper.getMainLooper()).postDelayed({
+ sessionRule.requestActivityToForeground(context)
+ }, 1500)
+
+ // Will cause onPause event to occur
+ sessionRule.simulatePressHome(context)
+
+ // After/During onPause Event
+ val whilePausingPosition = getCurrentPositionJSWithWait()
+ mockGpsProvider.stopPostingLocation()
+ assertThat("Latitude after/during onPause matches.", whilePausingPosition["latitude"] as Number, equalTo(afterPauseLat))
+ assertThat("Longitude after/during onPause matches.", whilePausingPosition["longitude"] as Number, equalTo(afterPauseLon))
+
+ assertThat("onResume count matches.", actualResumeCount, equalTo(2))
+ assertThat("onPause count matches.", actualPauseCount, equalTo(1))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt
new file mode 100644
index 0000000000..ef361a8860
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt
@@ -0,0 +1,63 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+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.GeckoRuntime
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash
+import org.mozilla.geckoview.test.util.UiThreadUtils
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class GpuCrashTest : BaseSessionTest() {
+ val client = TestCrashHandler.Client(InstrumentationRegistry.getInstrumentation().targetContext)
+
+ @Before
+ fun setup() {
+ assertTrue(client.connect(sessionRule.env.defaultTimeoutMillis))
+ client.setEvalNextCrashDump(GeckoRuntime.CRASHED_PROCESS_TYPE_BACKGROUND_CHILD)
+ }
+
+ @IgnoreCrash
+ @Test
+ fun crashGpu() {
+ // We need the crash reporter for this test
+ assumeTrue(BuildConfig.MOZ_CRASHREPORTER)
+
+ // We need the GPU process for this test
+ assumeTrue(sessionRule.usingGpuProcess())
+
+ // Cause the GPU process to crash.
+ sessionRule.crashGpuProcess()
+
+ val evalResult = client.getEvalResult(sessionRule.env.defaultTimeoutMillis)
+ assertTrue(evalResult.mMsg, evalResult.mResult)
+ }
+
+ @Test(expected = UiThreadUtils.TimeoutException::class)
+ fun killGpuNoCrashReport() {
+ // We need the crash reporter for this test
+ assumeTrue(BuildConfig.MOZ_CRASHREPORTER)
+
+ // We need the GPU process for this test
+ assumeTrue(sessionRule.usingGpuProcess())
+
+ // Cleanly kill GPU process
+ sessionRule.killGpuProcess()
+
+ // Expect this to time out as no crash should be reported
+ client.getEvalResult(sessionRule.env.defaultTimeoutMillis)
+ }
+
+ @After
+ fun teardown() {
+ client.disconnect()
+ }
+}
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..a4ec7c3139
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt
@@ -0,0 +1,303 @@
+/* -*- 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.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assume.assumeThat
+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.HistoryDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+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.
+ mainSession.loadUri(testUri)
+ mainSession.waitUntilCalled(
+ GeckoSession.HistoryDelegate::class,
+ "onVisited",
+ "getVisited",
+ )
+
+ // Sometimes link changes are not applied immediately, wait for a little bit
+ UiThreadUtils.waitForCondition({
+ mainSession.getLinkColor("#mozilla") == VISITED_COLOR
+ }, sessionRule.env.defaultTimeoutMillis)
+
+ assertThat(
+ "Mozilla should be visited",
+ mainSession.getLinkColor("#mozilla"),
+ equalTo(VISITED_COLOR),
+ )
+
+ assertThat(
+ "Test Pilot should be visited",
+ mainSession.getLinkColor("#testpilot"),
+ equalTo(VISITED_COLOR),
+ )
+
+ assertThat(
+ "Bugzilla should be unvisited",
+ mainSession.getLinkColor("#bugzilla"),
+ equalTo(UNVISITED_COLOR),
+ )
+ }
+
+ @Ignore // disable test on debug for frequent failures Bug 1544169
+ @Test
+ fun onHistoryStateChange() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : 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),
+ )
+ }
+ })
+
+ mainSession.loadTestPath(HELLO2_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : 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),
+ )
+ }
+ })
+
+ mainSession.goBack()
+
+ sessionRule.waitUntilCalled(object : 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),
+ )
+ }
+ })
+
+ mainSession.goForward()
+
+ sessionRule.waitUntilCalled(object : 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),
+ )
+ }
+ })
+
+ mainSession.gotoHistoryIndex(0)
+
+ sessionRule.waitUntilCalled(object : 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),
+ )
+ }
+ })
+
+ mainSession.gotoHistoryIndex(1)
+
+ sessionRule.waitUntilCalled(object : 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
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : 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),
+ )
+ }
+ })
+
+ mainSession.loadTestPath(HELLO2_HTML_PATH)
+
+ sessionRule.waitUntilCalled(object : 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..6d535b8ad1
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt
@@ -0,0 +1,306 @@
+/* -*- 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.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.gecko.util.ImageResource
+import org.mozilla.geckoview.GeckoResult
+
+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/InputResultDetailTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt
new file mode 100644
index 0000000000..cfe0bcaf12
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt
@@ -0,0 +1,417 @@
+package org.mozilla.geckoview.test
+
+import android.os.SystemClock
+import android.view.MotionEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.PanZoomController
+import org.mozilla.geckoview.PanZoomController.InputResultDetail
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class InputResultDetailTest : BaseSessionTest() {
+ private val scrollWaitTimeout = 10000.0 // 10 seconds
+
+ private fun setupDocument(documentPath: String) {
+ mainSession.loadTestPath(documentPath)
+ sessionRule.waitUntilCalled(object : ContentDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+ mainSession.flushApzRepaints()
+ }
+
+ private fun sendDownEvent(x: Float, y: Float): GeckoResult<InputResultDetail> {
+ val downTime = SystemClock.uptimeMillis()
+ val down = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_DOWN,
+ x,
+ y,
+ 0,
+ )
+
+ val result = mainSession.panZoomController.onTouchEventForDetailResult(down)
+
+ val up = MotionEvent.obtain(
+ downTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_UP,
+ x,
+ y,
+ 0,
+ )
+
+ mainSession.panZoomController.onTouchEvent(up)
+
+ return result
+ }
+
+ private fun assertResultDetail(
+ testName: String,
+ actual: InputResultDetail,
+ expectedHandledResult: Int,
+ expectedScrollableDirections: Int,
+ expectedOverscrollDirections: Int,
+ ) {
+ assertThat(
+ testName + ": The handled result",
+ actual.handledResult(),
+ equalTo(expectedHandledResult),
+ )
+ assertThat(
+ testName + ": The scrollable directions",
+ actual.scrollableDirections(),
+ equalTo(expectedScrollableDirections),
+ )
+ assertThat(
+ testName + ": The overscroll directions",
+ actual.overscrollDirections(),
+ equalTo(expectedOverscrollDirections),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testTouchAction() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+
+ for (subframe in arrayOf(true, false)) {
+ for (scrollable in arrayOf(true, false)) {
+ for (event in arrayOf(true, false)) {
+ for (touchAction in arrayOf("auto", "none", "pan-x", "pan-y")) {
+ var url = TOUCH_ACTION_HTML_PATH + "?"
+ if (subframe) {
+ url += "subframe&"
+ }
+ if (scrollable) {
+ url += "scrollable&"
+ }
+ if (event) {
+ url += "event&"
+ }
+ url += ("touch-action=" + touchAction)
+
+ setupDocument(url)
+
+ // Since sendDownEvent() just sends a touch-down, APZ doesn't
+ // yet know the direction, hence it allows scrolling in both
+ // the pan-x and pan-y cases.
+ var expectedPlace = if (touchAction == "none" || (subframe && scrollable)) {
+ PanZoomController.INPUT_RESULT_HANDLED_CONTENT
+ } else if (scrollable) {
+ PanZoomController.INPUT_RESULT_HANDLED
+ } else {
+ PanZoomController.INPUT_RESULT_UNHANDLED
+ }
+
+ var expectedScrollableDirections = if (scrollable) {
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM
+ } else {
+ PanZoomController.SCROLLABLE_FLAG_NONE
+ }
+
+ // FIXME: There are a couple of bugs here:
+ // 1. In the case where touch-action allows the scrolling, the
+ // overscroll directions shouldn't depend on the presence of
+ // an event handler, but they do.
+ // 2. In the case where touch-action doesn't allow the scrolling,
+ // the overscroll directions should probably be NONE.
+ var expectedOverscrollDirections = if (touchAction != "none" && !scrollable && event) {
+ PanZoomController.OVERSCROLL_FLAG_NONE
+ } else {
+ (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL)
+ }
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 20f))
+ assertResultDetail(
+ "`subframe=$subframe, scrollable=$scrollable, event=$event, touch-action=$touchAction`",
+ value,
+ expectedPlace,
+ expectedScrollableDirections,
+ expectedOverscrollDirections,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testScrollableWithDynamicToolbar() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+
+ // Set active since setVerticalClipping call affects only for forground tab.
+ mainSession.setActive(true)
+
+ setupDocument(ROOT_100VH_HTML_PATH + "?event")
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ assertResultDetail(
+ ROOT_100VH_HTML_PATH,
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL),
+ )
+
+ // Prepare a resize event listener.
+ val resizePromise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ window.visualViewport.addEventListener('resize', () => {
+ resolve(true);
+ }, { once: true });
+ });
+ """.trimIndent(),
+ )
+
+ // Hide the dynamic toolbar.
+ sessionRule.display?.run { setVerticalClipping(-20) }
+
+ // Wait a visualViewport resize event to make sure the toolbar change has been reflected.
+ assertThat("resize", resizePromise.value as Boolean, equalTo(true))
+
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ assertResultDetail(
+ ROOT_100VH_HTML_PATH,
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED,
+ PanZoomController.SCROLLABLE_FLAG_TOP,
+ (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testOverscrollBehaviorAuto() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+ setupDocument(OVERSCROLL_BEHAVIOR_AUTO_HTML_PATH)
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ assertResultDetail(
+ "`overscroll-behavior: auto`",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testOverscrollBehaviorAutoNone() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+ setupDocument(OVERSCROLL_BEHAVIOR_AUTO_NONE_HTML_PATH)
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ assertResultDetail(
+ "`overscroll-behavior: auto, none`",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ PanZoomController.OVERSCROLL_FLAG_HORIZONTAL,
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testOverscrollBehaviorNoneAuto() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+ setupDocument(OVERSCROLL_BEHAVIOR_NONE_AUTO_HTML_PATH)
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ assertResultDetail(
+ "`overscroll-behavior: none, auto`",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ PanZoomController.OVERSCROLL_FLAG_VERTICAL,
+ )
+ }
+
+ // NOTE: This function requires #scroll element in the target document.
+ private fun scrollToBottom() {
+ // Prepare a scroll event listener.
+ val scrollPromise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ const scroll = document.getElementById('scroll');
+ scroll.addEventListener('scroll', () => {
+ resolve(true);
+ }, { once: true });
+ });
+ """.trimIndent(),
+ )
+
+ // Scroll to the bottom edge of the scroll container.
+ mainSession.evaluateJS(
+ """
+ const scroll = document.getElementById('scroll');
+ scroll.scrollTo(0, scroll.scrollHeight);
+ """.trimIndent(),
+ )
+ assertThat("scroll", scrollPromise.value as Boolean, equalTo(true))
+ mainSession.flushApzRepaints()
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testScrollHandoff() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+ setupDocument(SCROLL_HANDOFF_HTML_PATH)
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ // There is a child scroll container and its overscroll-behavior is `contain auto`
+ assertResultDetail(
+ "handoff",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED_CONTENT,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ PanZoomController.OVERSCROLL_FLAG_VERTICAL,
+ )
+
+ // Scroll to the bottom edge
+ scrollToBottom()
+
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ // Now the touch event should be handed to the root scroller.
+ assertResultDetail(
+ "handoff",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testOverscrollBehaviorNoneOnNonRoot() {
+ var files = arrayOf(
+ OVERSCROLL_BEHAVIOR_NONE_NON_ROOT_HTML_PATH,
+ )
+
+ for (file in files) {
+ setupDocument(file)
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ assertResultDetail(
+ "`overscroll-behavior: none` on non root scroll container",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED_CONTENT,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ PanZoomController.OVERSCROLL_FLAG_NONE,
+ )
+
+ // Scroll to the bottom edge so that the container is no longer scrollable downwards.
+ scrollToBottom()
+
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ // The touch event should be handled in the scroll container content.
+ assertResultDetail(
+ "`overscroll-behavior: none` on non root scroll container",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED_CONTENT,
+ PanZoomController.SCROLLABLE_FLAG_TOP,
+ PanZoomController.OVERSCROLL_FLAG_NONE,
+ )
+ }
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun testOverscrollBehaviorNoneOnNonRootWithDynamicToolbar() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+
+ var files = arrayOf(
+ OVERSCROLL_BEHAVIOR_NONE_NON_ROOT_HTML_PATH,
+ )
+
+ for (file in files) {
+ setupDocument(file)
+
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ assertResultDetail(
+ "`overscroll-behavior: none` on non root scroll container",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED_CONTENT,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ PanZoomController.OVERSCROLL_FLAG_NONE,
+ )
+
+ // Scroll to the bottom edge so that the container is no longer scrollable downwards.
+ scrollToBottom()
+
+ value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ // Now the touch event should be handed to the root scroller even if
+ // the scroll container's `overscroll-behavior` is none to move
+ // the dynamic toolbar.
+ assertResultDetail(
+ "`overscroll-behavior: none, none`",
+ value,
+ PanZoomController.INPUT_RESULT_HANDLED,
+ PanZoomController.SCROLLABLE_FLAG_BOTTOM,
+ (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL),
+ )
+ }
+ }
+
+ // Tests a situation where converting a scrollport size between CSS units and app units will
+ // result different values, and the difference causes an issue that unscrollable documents
+ // behave as if it's scrollable.
+ //
+ // Note about metrics that this test uses.
+ // A basic here is that documents having no meta viewport tags are laid out on 980px width
+ // canvas, the 980px is defined as "browser.viewport.desktopWidth".
+ //
+ // So, if the device screen size is (1080px, 2160px) then the document is scaled to
+ // (1080 / 980) = 1.10204. Then if the dynamic toolbar height is 59px, the scaled document
+ // height is (2160 - 59) / 1.10204 = 1906.46 (in CSS units). It's converted and actually rounded
+ // to 114388 (= 1906.46 * 60) in app units. And it's converted back to 1906.47 (114388 / 60) in
+ // CSS units unfortunately.
+ @WithDisplay(width = 1080, height = 2160)
+ @Test
+ fun testFractionalScrollPortSize() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "browser.viewport.desktopWidth" to 980,
+ ),
+ )
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(59) }
+
+ setupDocument(NO_META_VIEWPORT_HTML_PATH)
+
+ // Try to scroll down to see if the document is scrollable or not.
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+
+ assertResultDetail(
+ "The document isn't not scrollable at all",
+ value,
+ PanZoomController.INPUT_RESULT_UNHANDLED,
+ PanZoomController.SCROLLABLE_FLAG_NONE,
+ (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL),
+ )
+ }
+}
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..69deac1c89
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt
@@ -0,0 +1,43 @@
+/* -*- 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.Matchers.* // ktlint-disable no-wildcard-imports
+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),
+ )
+ }
+
+ @Test fun duplicateLocales() {
+ sessionRule.runtime.settings.setLocales(arrayOf("en-gb", "en-US", "en-gb", "en-fr", "en-us", "en-FR"))
+ assertThat(
+ "Locales have no duplicates",
+ sessionRule.requestedLocales,
+ equalTo(listOf("en-GB", "en-US", "en-FR")),
+ )
+ }
+
+ @Test fun lowerCaseToUpperCaseLocales() {
+ sessionRule.runtime.settings.setLocales(arrayOf("en-gb", "en-us", "en-fr"))
+ assertThat(
+ "Locales are formatted properly",
+ sessionRule.requestedLocales,
+ equalTo(listOf("en-GB", "en-US", "en-FR")),
+ )
+ }
+}
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..19488835e3
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt
@@ -0,0 +1,177 @@
+/* -*- 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.Matchers
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.MediaDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+@Suppress("DEPRECATION")
+class MediaDelegateTest : BaseSessionTest() {
+
+ private fun requestRecordingPermission(allowAudio: Boolean, allowCamera: Boolean) {
+ mainSession.delegateDuringNextWait(object : 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 : MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onRecordingStatusChanged(
+ session: GeckoSession,
+ devices: Array<org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice>,
+ ) {
+ var audioActive = false
+ var cameraActive = false
+ for (device in devices) {
+ if (device.type == org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice.Type.MICROPHONE) {
+ audioActive = device.status != org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice.Status.INACTIVE
+ }
+ if (device.type == org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice.Type.CAMERA) {
+ cameraActive = device.status != org.mozilla.geckoview.GeckoSession.MediaDelegate.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() {
+ // TODO: needs bug 1700243
+ assumeThat(sessionRule.env.isIsolatedProcess, Matchers.equalTo(false))
+
+ 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..2caa71fc71
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt
@@ -0,0 +1,197 @@
+/* -*- 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.Matchers
+import org.json.JSONObject
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.MediaDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+@Suppress("DEPRECATION")
+class MediaDelegateXOriginTest : BaseSessionTest() {
+
+ private fun requestRecordingPermission(allowAudio: Boolean, allowCamera: Boolean) {
+ mainSession.delegateDuringNextWait(object : 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 : MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onRecordingStatusChanged(
+ session: GeckoSession,
+ devices: Array<MediaDelegate.RecordingDevice>,
+ ) {
+ var audioActive = false
+ var cameraActive = false
+ for (device in devices) {
+ if (device.type == MediaDelegate.RecordingDevice.Type.MICROPHONE) {
+ audioActive = device.status != MediaDelegate.RecordingDevice.Status.INACTIVE
+ }
+ if (device.type == MediaDelegate.RecordingDevice.Type.CAMERA) {
+ cameraActive = device.status != MediaDelegate.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 : 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 : MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 0)
+ override fun onRecordingStatusChanged(
+ session: GeckoSession,
+ devices: Array<org.mozilla.geckoview.GeckoSession.MediaDelegate.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: needs bug 1700243
+ assumeThat(sessionRule.env.isIsolatedProcess, 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() {
+ 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/MediaSessionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt
new file mode 100644
index 0000000000..ac0e69663c
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt
@@ -0,0 +1,1031 @@
+/* -*- 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.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.After
+import org.junit.Assume.assumeThat
+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.MediaSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+
+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.34
+ 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() {
+ // TODO: needs bug 1700243
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ 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 : MediaSession.Delegate {
+ @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() {
+ // TODO: needs bug 1700243
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ 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 : MediaSession.Delegate {
+ @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() {
+ // TODO: needs bug 1700243
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ 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 : MediaSession.Delegate {
+ @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
+ override fun onMetadata(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ meta: MediaSession.Metadata,
+ ) {
+ val count = sessionRule.currentCall.counter
+ if (count < 3) {
+ // Ignore redundant calls.
+ onMetadataCalled[0][count - 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 : MediaSession.Delegate {
+ @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
+ override fun onMetadata(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ meta: MediaSession.Metadata,
+ ) {
+ val count = sessionRule.currentCall.counter
+ if (count < 2) {
+ // Ignore redundant calls.
+ onMetadataCalled[1][0].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() {
+ // TODO: bug 1810736
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ 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 : MediaSession.Delegate {
+ @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)
+ }
+
+ @Test
+ fun fullscreenVideoWithActivated() {
+ // TODO: bug 1810736
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "media.autoplay.default" to 0,
+ "full-screen-api.allow-trusted-requests-only" to false,
+ ),
+ )
+
+ val path = VIDEO_WEBM_PATH
+ val session = sessionRule.createOpenSession()
+ val resultFullscreen = GeckoResult<Void>()
+ session.loadTestPath(path)
+ sessionRule.waitForPageStop()
+
+ session.delegateDuringNextWait(object : MediaSession.Delegate {
+ override fun onFullscreen(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ enabled: Boolean,
+ meta: MediaSession.ElementMetadata?,
+ ) {
+ assertThat(
+ "Fullscreen should be enabled",
+ enabled,
+ equalTo(true),
+ )
+ assertThat(
+ "Element metadata should exist",
+ meta,
+ notNullValue(),
+ )
+ resultFullscreen.complete(null)
+ }
+ })
+
+ session.evaluateJS("document.querySelector('video').requestFullscreen()")
+ sessionRule.waitForResult(resultFullscreen)
+ }
+
+ @Test
+ fun switchingProcess() {
+ // TODO: bug 1810736
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "media.autoplay.default" to 0,
+ ),
+ )
+
+ mainSession.loadUri("about:blank")
+ sessionRule.waitForPageStop()
+
+ mainSession.loadTestPath(VIDEO_WEBM_PATH)
+ sessionRule.waitForPageStop()
+
+ val onPlayCalled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : MediaSession.Delegate {
+ @AssertCalled(count = 1)
+ override fun onPlay(
+ session: GeckoSession,
+ mediaSession: MediaSession,
+ ) {
+ onPlayCalled.complete(null)
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('video').play()")
+ sessionRule.waitForResult(onPlayCalled)
+ }
+}
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..b218cf9838
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java
@@ -0,0 +1,213 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.junit.Assert.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.MultiMap;
+
+@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));
+
+ final 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));
+
+ final 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));
+
+ final 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");
+
+ final 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
+ final 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
+ final 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..f688b498f5
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt
@@ -0,0 +1,3126 @@
+/* -*- 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.Bitmap
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Base64
+import android.view.KeyEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.GeckoSession.HistoryDelegate
+import org.mozilla.geckoview.GeckoSession.Loader
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.GeckoSession.TextInputDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.test.util.UiThreadUtils
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.ThreadLocalRandom
+import kotlin.concurrent.thread
+
+@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 : ProgressDelegate, NavigationDelegate, 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))
+ }
+ },
+ )
+
+ mainSession.load(testLoader)
+ sessionRule.waitForPageStop()
+
+ if (errorPageUrl != null) {
+ sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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 : ProgressDelegate, NavigationDelegate, 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) {
+ }
+ },
+ )
+
+ mainSession.loadUri(testUri)
+ sessionRule.waitUntilCalled(NavigationDelegate::class, "onLoadError")
+
+ if (errorPageUrl != null) {
+ sessionRule.waitUntilCalled(object : 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(GeckoSession.LOAD_FLAGS_EXTERNAL),
+ WebRequestError.ERROR_CATEGORY_UNKNOWN,
+ WebRequestError.ERROR_UNKNOWN,
+ )
+ testLoadExpectError(
+ TestLoader()
+ .uri("resource://gre/")
+ .flags(GeckoSession.LOAD_FLAGS_EXTERNAL),
+ WebRequestError.ERROR_CATEGORY_UNKNOWN,
+ WebRequestError.ERROR_UNKNOWN,
+ )
+ testLoadExpectError(
+ TestLoader()
+ .uri("about:about")
+ .flags(GeckoSession.LOAD_FLAGS_EXTERNAL),
+ WebRequestError.ERROR_CATEGORY_UNKNOWN,
+ WebRequestError.ERROR_UNKNOWN,
+ )
+ testLoadExpectError(
+ TestLoader()
+ .uri("resource://android/assets/web_extensions/")
+ .flags(GeckoSession.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 : ProgressDelegate, NavigationDelegate, 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 onSecurityChange(
+ session: GeckoSession,
+ securityInfo: ProgressDelegate.SecurityInformation,
+ ) {
+ assertThat("Should be exception", securityInfo.isException, equalTo(true))
+ assertThat("Should not be secure", securityInfo.isSecure, equalTo(false))
+ }
+
+ @AssertCalled(count = 1, order = [3])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should succeed", success, equalTo(true))
+ sessionRule.removeAllCertOverrides()
+ }
+ },
+ )
+ mainSession.evaluateJS("location.reload()")
+ mainSession.waitForPageStop()
+ }
+
+ @Test fun loadWithHTTPSOnlyMode() {
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY)
+
+ val httpsFirstPref = "dom.security.https_first"
+ val httpsFirstPrefValue = (sessionRule.getPrefs(httpsFirstPref)[0] as Boolean)
+
+ val httpsFirstPBMPref = "dom.security.https_first_pbm"
+ val httpsFirstPBMPrefValue = (sessionRule.getPrefs(httpsFirstPBMPref)[0] as Boolean)
+
+ val insecureUri = if (sessionRule.env.isAutomation) {
+ "http://nocert.example.com/"
+ } else {
+ "http://neverssl.com"
+ }
+
+ val secureUri = if (sessionRule.env.isAutomation) {
+ "http://example.com/"
+ } else {
+ "http://neverssl.com"
+ }
+
+ mainSession.loadUri(insecureUri)
+ mainSession.waitForPageStop()
+
+ mainSession.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ assertThat("categories should match", error.category, equalTo(WebRequestError.ERROR_CATEGORY_NETWORK))
+ assertThat("codes should match", error.code, equalTo(WebRequestError.ERROR_HTTPS_ONLY))
+ return null
+ }
+ })
+
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL)
+
+ mainSession.loadUri(secureUri)
+ mainSession.waitForPageStop()
+
+ var onLoadCalledCounter = 0
+ mainSession.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 0)
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ onLoadCalledCounter++
+ return null
+ }
+ })
+
+ if (httpsFirstPrefValue) {
+ // if https-first is enabled we get two calls to onLoadRequest
+ // (1) http://example.com/ and (2) https://example.com/
+ assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(2))
+ } else {
+ assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(1))
+ }
+
+ val privateSession = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .usePrivateMode(true)
+ .build(),
+ )
+
+ privateSession.loadUri(secureUri)
+ privateSession.waitForPageStop()
+
+ onLoadCalledCounter = 0
+ privateSession.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 0)
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ onLoadCalledCounter++
+ return null
+ }
+ })
+
+ if (httpsFirstPBMPrefValue) {
+ // if https-first is enabled we get two calls to onLoadRequest
+ // (1) http://example.com/ and (2) https://example.com/
+ assertThat("Assert count privateSession.onLoadRequest", onLoadCalledCounter, equalTo(2))
+ } else {
+ assertThat("Assert count privateSession.onLoadRequest", onLoadCalledCounter, equalTo(1))
+ }
+
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY_PRIVATE)
+
+ privateSession.loadUri(insecureUri)
+ privateSession.waitForPageStop()
+
+ privateSession.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ assertThat("categories should match", error.category, equalTo(WebRequestError.ERROR_CATEGORY_NETWORK))
+ assertThat("codes should match", error.code, equalTo(WebRequestError.ERROR_HTTPS_ONLY))
+ return null
+ }
+ })
+
+ mainSession.loadUri(secureUri)
+ mainSession.waitForPageStop()
+
+ onLoadCalledCounter = 0
+ mainSession.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 0)
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ return null
+ }
+
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ onLoadCalledCounter++
+ return null
+ }
+ })
+
+ if (httpsFirstPrefValue) {
+ // if https-first is enabled we get two calls to onLoadRequest
+ // (1) http://example.com/ and (2) https://example.com/
+ assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(2))
+ } else {
+ assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(1))
+ }
+
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL)
+ }
+
+ // Due to Bug 1692578 we currently cannot test bypassing of the error
+ // the URI loading process takes the desktop path for iframes
+ @Test fun loadHTTPSOnlyInSubframe() {
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY)
+
+ val uri = "http://example.org/tests/junit/iframe_http_only.html"
+ val httpsUri = "https://example.org/tests/junit/iframe_http_only.html"
+ val iFrameUri = "http://expired.example.com/"
+ val iFrameHttpsUri = "https://expired.example.com/"
+
+ val testLoader = TestLoader().uri(uri)
+
+ sessionRule.delegateDuringNextWait(
+ object : ProgressDelegate, NavigationDelegate, ContentDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("The URLs must match", request.uri, equalTo(forEachCall(uri, httpsUri)))
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat(
+ "URI should be " + uri,
+ url,
+ equalTo(uri),
+ )
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should fail", success, equalTo(true))
+ }
+
+ @AssertCalled(count = 2)
+ override fun onSubframeLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ assertThat("URI should not be null", request.uri, notNullValue())
+ assertThat("URI should match", request.uri, equalTo(forEachCall(iFrameUri, iFrameHttpsUri)))
+ return GeckoResult.allow()
+ }
+ },
+ )
+
+ mainSession.load(testLoader)
+ sessionRule.waitForPageStop()
+
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL)
+ }
+
+ @Test fun bypassHTTPSOnlyError() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY)
+
+ val host = if (sessionRule.env.isAutomation) {
+ "expired.example.com"
+ } else {
+ "expired.badssl.com"
+ }
+
+ val uri = "http://$host/"
+ val httpsUri = "https://$host/"
+
+ val testLoader = TestLoader().uri(uri)
+
+ // The two loads below follow testLoadExpectError(TestLoader, Int, Int) flow
+
+ sessionRule.delegateDuringNextWait(
+ object : ProgressDelegate, NavigationDelegate, ContentDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("The URLs must match", request.uri, equalTo(forEachCall(uri, httpsUri)))
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStart(session: GeckoSession, url: String) {
+ assertThat(
+ "URI should be " + uri,
+ url,
+ equalTo(uri),
+ )
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ assertThat(
+ "Error code should match",
+ error.code,
+ equalTo(WebRequestError.ERROR_HTTPS_ONLY),
+ )
+ return GeckoResult.fromValue(createTestUrl(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should fail", success, equalTo(false))
+ }
+ },
+ )
+
+ mainSession.load(testLoader)
+ sessionRule.waitForPageStop()
+
+ sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ assertThat("URL should match", url, equalTo(httpsUri))
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should not be empty", title, not(isEmptyOrNullString()))
+ }
+ })
+
+ sessionRule.delegateDuringNextWait(
+ object : ProgressDelegate, NavigationDelegate, ContentDelegate {
+ @AssertCalled(count = 2, order = [1, 3])
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("The URLs must match", request.uri, equalTo(forEachCall(uri, httpsUri)))
+ return null
+ }
+
+ @AssertCalled(count = 1, order = [4])
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ assertThat(
+ "Error code should match",
+ error.code,
+ equalTo(WebRequestError.ERROR_HTTPS_ONLY),
+ )
+ return GeckoResult.fromValue(null)
+ }
+
+ @AssertCalled(count = 1, order = [5])
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Load should fail", success, equalTo(false))
+ }
+ },
+ )
+
+ mainSession.load(testLoader)
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(
+ object : ProgressDelegate, NavigationDelegate, ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ // We set http scheme only in case it's not iFrame
+ assertThat("The URLs must match", request.uri, equalTo(uri))
+ return null
+ }
+
+ @AssertCalled(count = 0)
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ return null
+ }
+ },
+ )
+
+ mainSession.waitForJS("document.reloadWithHttpsOnlyException()")
+ mainSession.waitForPageStop()
+
+ sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL)
+ }
+
+ @Test fun loadHSTSBadCert() {
+ val httpsFirstPref = "dom.security.https_first"
+ assertThat("https pref should be false", sessionRule.getPrefs(httpsFirstPref)[0] as Boolean, equalTo(false))
+
+ // load secure url with hsts header
+ val uri = "https://example.com/tests/junit/hsts_header.sjs"
+ mainSession.loadUri(uri)
+ mainSession.waitForPageStop()
+
+ // load insecure subdomain url to see if it gets upgraded to https
+ val http_uri = "http://test1.example.com/"
+ val https_uri = "https://test1.example.com/"
+
+ mainSession.loadUri(http_uri)
+ mainSession.waitForPageStop()
+
+ mainSession.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ assertThat(
+ "URI should be HTTP then redirected to HTTPS",
+ request.uri,
+ equalTo(forEachCall(http_uri, https_uri)),
+ )
+ return null
+ }
+ })
+
+ // load subdomain that will trigger the cert error
+ val no_cert_uri = "https://nocert.example.com/"
+ mainSession.loadUri(no_cert_uri)
+ mainSession.waitForPageStop()
+
+ mainSession.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ assertThat("categories should match", error.category, equalTo(WebRequestError.ERROR_CATEGORY_NETWORK))
+ assertThat("codes should match", error.code, equalTo(WebRequestError.ERROR_BAD_HSTS_CERT))
+ return null
+ }
+ })
+ sessionRule.clearHSTSState()
+ }
+
+ @Ignore // Disabled for bug 1619344.
+ @Test
+ fun loadUnknownProtocol() {
+ testLoadEarlyError(
+ UNKNOWN_PROTOCOL_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ WebRequestError.ERROR_UNKNOWN_PROTOCOL,
+ )
+ }
+
+ // Due to Bug 1692578 we currently cannot test displaying the error
+ // the URI loading process takes the desktop path for iframes
+ @Test fun loadUnknownProtocolIframe() {
+ // Should match iframe URI from IFRAME_UNKNOWN_PROTOCOL
+ val iframeUri = "foo://bar"
+ mainSession.loadTestPath(IFRAME_UNKNOWN_PROTOCOL)
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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)
+ mainSession.loadTestPath(TRACKERS_PATH)
+
+ sessionRule.waitUntilCalled(
+ object : ContentBlocking.Delegate {
+ @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) {
+ }
+ },
+ )
+
+ mainSession.settings.useTrackingProtection = false
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : ContentBlocking.Delegate {
+ @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) {
+ "https://example.org/tests/junit/hello.html"
+ } else {
+ "https://jigsaw.w3.org/HTTP/300/Overview.html"
+ }
+ val uri = if (sessionRule.env.isAutomation) {
+ "https://example.org/tests/junit/simple_redirect.sjs?$redirectUri"
+ } else {
+ "https://jigsaw.w3.org/HTTP/300/301.html"
+ }
+
+ mainSession.loadUri(uri)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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(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
+ }
+
+ mainSession.loadTestPath(path)
+ sessionRule.waitForPageStop()
+
+ // We shouldn't be firing onLoadRequest for iframes, including redirects.
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ val redirectUri = if (sessionRule.env.isAutomation) {
+ "https://example.org/tests/junit/hello.html"
+ } else {
+ "https://jigsaw.w3.org/HTTP/300/Overview.html"
+ }
+ val uri = if (sessionRule.env.isAutomation) {
+ "https://example.org/tests/junit/simple_redirect.sjs?$redirectUri"
+ } else {
+ "https://jigsaw.w3.org/HTTP/300/301.html"
+ }
+
+ sessionRule.delegateDuringNextWait(
+ object : 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(NavigationDelegate.TARGET_WINDOW_CURRENT),
+ )
+ assertThat(
+ "Redirect flag is set",
+ request.isRedirect,
+ equalTo(forEachCall(false, true)),
+ )
+
+ return forEachCall(GeckoResult.allow(), GeckoResult.deny())
+ }
+ },
+ )
+
+ mainSession.loadUri(uri)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : 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))
+
+ val redirectUri = "intent://test"
+ val uri = "https://example.org/tests/junit/simple_redirect.sjs?$redirectUri"
+
+ mainSession.loadUri(uri)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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)
+
+ mainSession.load(
+ Loader()
+ .uri(phishingUri + "?bypass=true")
+ .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER),
+ )
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : 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)
+
+ mainSession.loadUri(phishingUri + "?block=false")
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : 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)
+
+ mainSession.loadUri(malwareUri + "?block=false")
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : 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)
+
+ mainSession.loadUri(unwantedUri + "?block=false")
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : 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)
+
+ mainSession.loadUri(harmfulUri + "?block=false")
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : 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(mainSession.userAgent)
+ assertThat(
+ "Mobile user agent should match the default user agent",
+ userAgent,
+ equalTo(GeckoSession.getDefaultUserAgent()),
+ )
+ }
+
+ @Test fun desktopMode() {
+ mainSession.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(mainSession.userAgent)
+ assertThat(
+ "User agent should be reported as mobile",
+ userAgent,
+ containsString(mobileSubStr),
+ )
+
+ mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_DESKTOP
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "User agent should be set to desktop",
+ getUserAgent(),
+ containsString(desktopSubStr),
+ )
+
+ userAgent = sessionRule.waitForResult(mainSession.userAgent)
+ assertThat(
+ "User agent should be reported as desktop",
+ userAgent,
+ containsString(desktopSubStr),
+ )
+
+ mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_MOBILE
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "User agent should be set to mobile",
+ getUserAgent(),
+ containsString(mobileSubStr),
+ )
+
+ userAgent = sessionRule.waitForResult(mainSession.userAgent)
+ assertThat(
+ "User agent should be reported as mobile",
+ userAgent,
+ containsString(mobileSubStr),
+ )
+
+ val vrSubStr = "Mobile VR"
+ mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_VR
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "User agent should be set to VR",
+ getUserAgent(),
+ containsString(vrSubStr),
+ )
+
+ userAgent = sessionRule.waitForResult(mainSession.userAgent)
+ assertThat(
+ "User agent should be reported as VR",
+ userAgent,
+ containsString(vrSubStr),
+ )
+ }
+
+ private fun getUserAgent(session: GeckoSession = mainSession): 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() {
+ mainSession.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),
+ )
+
+ mainSession.settings.userAgentOverride = overrideUserAgent
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "User agent should be reported as override",
+ getUserAgent(),
+ equalTo(overrideUserAgent),
+ )
+
+ mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_VR
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "User agent should still be reported as override even when USER_AGENT_MODE is set",
+ getUserAgent(),
+ equalTo(overrideUserAgent),
+ )
+
+ mainSession.settings.userAgentOverride = null
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "User agent should now be reported as VR",
+ getUserAgent(),
+ containsString(vrSubStr),
+ )
+
+ sessionRule.delegateDuringNextWait(object : NavigationDelegate {
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ mainSession.settings.userAgentOverride = overrideUserAgent
+ return null
+ }
+ })
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "User agent should be reported as override after being set in onLoadRequest",
+ getUserAgent(),
+ equalTo(overrideUserAgent),
+ )
+
+ sessionRule.delegateDuringNextWait(object : NavigationDelegate {
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ mainSession.settings.userAgentOverride = null
+ return null
+ }
+ })
+
+ mainSession.reload()
+ mainSession.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() {
+ mainSession.loadTestPath(VIEWPORT_PATH)
+ sessionRule.waitForPageStop()
+
+ val desktopInnerWidth = 980.0
+ val physicalWidth = 600.0
+ val pixelRatio = mainSession.evaluateJS("window.devicePixelRatio") as Double
+ val mobileInnerWidth = physicalWidth / pixelRatio
+ val innerWidthJs = "window.innerWidth"
+
+ var innerWidth = mainSession.evaluateJS(innerWidthJs) as Double
+ assertThat(
+ "innerWidth should be equal to $mobileInnerWidth",
+ innerWidth,
+ closeTo(mobileInnerWidth, 0.1),
+ )
+
+ mainSession.settings.viewportMode = GeckoSessionSettings.VIEWPORT_MODE_DESKTOP
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ innerWidth = mainSession.evaluateJS(innerWidthJs) as Double
+ assertThat(
+ "innerWidth should be equal to $desktopInnerWidth",
+ innerWidth,
+ closeTo(desktopInnerWidth, 0.1),
+ )
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ innerWidth = mainSession.evaluateJS(innerWidthJs) as Double
+ assertThat(
+ "after navigation innerWidth should be equal to $desktopInnerWidth",
+ innerWidth,
+ closeTo(desktopInnerWidth, 0.1),
+ )
+
+ mainSession.loadTestPath(VIEWPORT_PATH)
+ sessionRule.waitForPageStop()
+
+ innerWidth = mainSession.evaluateJS(innerWidthJs) as Double
+ assertThat(
+ "after navigting back innerWidth should be equal to $desktopInnerWidth",
+ innerWidth,
+ closeTo(desktopInnerWidth, 0.1),
+ )
+
+ mainSession.settings.viewportMode = GeckoSessionSettings.VIEWPORT_MODE_MOBILE
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ innerWidth = mainSession.evaluateJS(innerWidthJs) as Double
+ assertThat(
+ "innerWidth should be equal to $mobileInnerWidth again",
+ innerWidth,
+ closeTo(mobileInnerWidth, 0.1),
+ )
+ }
+
+ @Test fun load() {
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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(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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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!"
+ mainSession.loadUri(dataUrl)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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(NavigationDelegate::class)
+ @Test
+ fun load_withoutNavigationDelegate() {
+ // Test that when navigation delegate is disabled, we can still perform loads.
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @NullDelegate(NavigationDelegate::class)
+ @Test
+ fun load_canUnsetNavigationDelegate() {
+ // Test that if we unset the navigation delegate during a load, the load still proceeds.
+ var onLocationCount = 0
+ mainSession.navigationDelegate = object : NavigationDelegate {
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ onLocationCount++
+ }
+ }
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "Should get callback for first load",
+ onLocationCount,
+ equalTo(1),
+ )
+
+ mainSession.reload()
+ mainSession.navigationDelegate = null
+ mainSession.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"
+ mainSession.load(Loader().data(dataString, mimeType))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate, 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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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() {
+ mainSession.load(Loader().data("Hello, World!", null))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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))
+
+ mainSession.load(Loader().data(bytes, "text/html"))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate, 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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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))
+
+ mainSession.load(Loader().data(bytes, mimeType))
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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() {
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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(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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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() {
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH))
+ }
+ })
+
+ mainSession.goBack()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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
+ }
+ })
+
+ mainSession.goForward()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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 : NavigationDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ if (request.uri.endsWith(HELLO_HTML_PATH)) {
+ return GeckoResult.deny()
+ } else {
+ return GeckoResult.allow()
+ }
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.loadTestPath(HELLO2_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("window.open('newSession_child.html', '_blank')")
+
+ mainSession.waitUntilCalled(object : 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(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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()")
+
+ mainSession.waitUntilCalled(object : 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(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)
+
+ mainSession.delegateDuringNextWait(object : 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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val newSession = delegateNewSession()
+ mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()")
+ // Initial about:blank
+ newSession.waitForPageStop()
+ // NEW_SESSION_CHILD_HTML_PATH
+ newSession.waitForPageStop()
+
+ newSession.forCallbacksDuringWait(object : 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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val newSession = delegateNewSession()
+ mainSession.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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val newSession = delegateNewSession()
+ mainSession.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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ // Pretend we handled the target="_blank" link click.
+ if (request.uri.endsWith(NEW_SESSION_CHILD_HTML_PATH)) {
+ return GeckoResult.deny()
+ } else {
+ return GeckoResult.allow()
+ }
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()")
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ // Assert that onNewSession was not called for the link click.
+ mainSession.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(FORM_BLANK_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ mainSession.evaluateJS(
+ """
+ document.querySelector('input[type=text]').focus()
+ """,
+ )
+ mainSession.waitUntilCalled(
+ TextInputDelegate::class,
+ "restartInput",
+ )
+
+ val time = SystemClock.uptimeMillis()
+ val keyEvent = KeyEvent(time, time, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER, 0)
+ mainSession.textInput.onKeyDown(KeyEvent.KEYCODE_ENTER, keyEvent)
+ mainSession.textInput.onKeyUp(
+ KeyEvent.KEYCODE_ENTER,
+ KeyEvent.changeAction(
+ keyEvent,
+ KeyEvent.ACTION_UP,
+ ),
+ )
+
+ mainSession.waitUntilCalled(object : 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(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/"
+
+ mainSession.load(
+ Loader()
+ .uri(uri)
+ .referrer(referrer)
+ .flags(GeckoSession.LOAD_FLAGS_NONE),
+ )
+ mainSession.waitForPageStop()
+
+ assertThat(
+ "Referrer should match",
+ mainSession.evaluateJS("document.referrer") as String,
+ equalTo(referrer),
+ )
+ }
+
+ @Test fun loadUriReferrerSession() {
+ val uri = "https://example.com/bar"
+ val referrer = "https://example.org/"
+
+ mainSession.loadUri(referrer)
+ mainSession.waitForPageStop()
+
+ val newSession = sessionRule.createOpenSession()
+ newSession.load(
+ Loader()
+ .uri(uri)
+ .referrer(mainSession)
+ .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"
+
+ mainSession.loadUri(referrer)
+ mainSession.waitForPageStop()
+
+ val newSession = sessionRule.createOpenSession()
+ newSession.load(
+ Loader()
+ .uri(uri)
+ .referrer(mainSession)
+ .flags(GeckoSession.LOAD_FLAGS_NONE),
+ )
+ newSession.waitUntilCalled(object : 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
+ mainSession.loadUri("$TEST_ENDPOINT/anything")
+ mainSession.waitForPageStop()
+
+ val defaultContent = mainSession.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
+ mainSession.load(
+ Loader()
+ .uri("$TEST_ENDPOINT/anything")
+ .additionalHeaders(headers)
+ .headerFilter(filter),
+ )
+ mainSession.waitForPageStop()
+
+ val content = mainSession.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(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer("test-referrer"),
+ Loader().uri("http://test-uri-equals.com")
+ .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer("test-referrer"),
+ true,
+ )
+ testLoaderEquals(
+ Loader().uri("http://test-uri-equals.com")
+ .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer(mainSession),
+ Loader().uri("http://test-uri-equals.com")
+ .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER)
+ .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE)
+ .referrer("test-referrer"),
+ false,
+ )
+
+ testLoaderEquals(
+ Loader().referrer(mainSession)
+ .data("testtest", "text/plain"),
+ Loader().referrer(mainSession)
+ .data("testtest", "text/plain"),
+ true,
+ )
+ testLoaderEquals(
+ Loader().referrer(mainSession)
+ .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))
+
+ mainSession.loadTestPath(NEW_SESSION_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession> {
+ return GeckoResult.fromValue(sessionRule.createOpenSession())
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()")
+
+ mainSession.waitUntilCalled(
+ 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.delegateUntilTestEnd(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.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 : NavigationDelegate {
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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))
+
+ mainSession.goBack()
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, endsWith(HELLO_HTML_PATH))
+ assertThat(
+ "docShell should be active after switching process",
+ mainSession.active,
+ equalTo(true),
+ )
+
+ mainSession.goBack()
+ sessionRule.waitForPageStop()
+
+ assertThat("URL should match", currentUrl!!, equalTo(url))
+
+ mainSession.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() {
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitForPageStop()
+
+ mainSession.evaluateJS("location.hash = 'test1';")
+
+ mainSession.waitUntilCalled(object : 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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ assertThat("URI should match", url, endsWith("#test1"))
+ }
+ })
+
+ mainSession.evaluateJS("location.hash = 'test2';")
+
+ mainSession.waitUntilCalled(object : 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?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ assertThat("URI should match", url, endsWith("#test2"))
+ }
+ })
+ }
+
+ @Test fun purgeHistory() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ sessionRule.waitUntilCalled(object : HistoryDelegate, 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))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) {
+ assertThat("History should have one entry", state.size, equalTo(1))
+ }
+ })
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH")
+ sessionRule.waitUntilCalled(object : HistoryDelegate, 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(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: HistoryDelegate.HistoryList) {
+ assertThat("History should have two entries", state.size, equalTo(2))
+ }
+ })
+ mainSession.purgeHistory()
+ sessionRule.waitUntilCalled(object : HistoryDelegate, NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: 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 : 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.allow()
+ }
+ })
+ }
+
+ @Test fun loadAfterLoad() {
+ mainSession.delegateDuringNextWait(object : 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.allow()
+ }
+ })
+
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH")
+ mainSession.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH")
+ mainSession.waitForPageStop()
+ }
+
+ @Test
+ fun loadLongDataUriToplevelDirect() {
+ val dataBytes = ByteArray(3 * 1024 * 1024)
+ val expectedUri = createDataUri(dataBytes, "*/*")
+ val loader = Loader().data(dataBytes, "*/*")
+
+ mainSession.delegateUntilTestEnd(object : NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ assertThat("URLs should match", request.uri, equalTo(expectedUri))
+ return GeckoResult.allow()
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ assertThat(
+ "Error category should match",
+ error.category,
+ equalTo(WebRequestError.ERROR_CATEGORY_URI),
+ )
+ assertThat(
+ "Error code should match",
+ error.code,
+ equalTo(WebRequestError.ERROR_DATA_URI_TOO_LONG),
+ )
+ assertThat("URLs should match", uri, equalTo(expectedUri))
+ return null
+ }
+ })
+
+ mainSession.load(loader)
+ sessionRule.waitUntilCalled(NavigationDelegate::class, "onLoadError")
+ }
+
+ @Test
+ fun loadLongDataUriToplevelIndirect() {
+ val dataBytes = ByteArray(3 * 1024 * 1024)
+ val dataUri = createDataUri(dataBytes, "*/*")
+
+ mainSession.loadTestPath(DATA_URI_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateUntilTestEnd(object : NavigationDelegate {
+ @AssertCalled(false)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ return GeckoResult.deny()
+ }
+ })
+
+ mainSession.evaluateJS("document.querySelector('#largeLink').href = \"$dataUri\"")
+ mainSession.evaluateJS("document.querySelector('#largeLink').click()")
+ mainSession.waitForPageStop()
+ }
+
+ @Test
+ @NullDelegate(NavigationDelegate::class)
+ fun loadOnBackgroundThreadNullNavigationDelegate() {
+ thread {
+ // Make sure we're running in a thread without a Looper.
+ assertThat(
+ "We should not have a looper.",
+ Looper.myLooper(),
+ equalTo(null),
+ )
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ }
+
+ mainSession.waitUntilCalled(object : ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page loaded successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test
+ fun invalidScheme() {
+ val invalidUri = "tel:#12345678"
+ mainSession.loadUri(invalidUri)
+ mainSession.waitUntilCalled(object : NavigationDelegate {
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ assertThat("Uri should match", uri, equalTo(invalidUri))
+ assertThat(
+ "error should match",
+ error.code,
+ equalTo(WebRequestError.ERROR_MALFORMED_URI),
+ )
+ assertThat(
+ "error should match",
+ error.category,
+ equalTo(WebRequestError.ERROR_CATEGORY_URI),
+ )
+ return null
+ }
+ })
+ }
+
+ @Test
+ fun loadOnBackgroundThread() {
+ mainSession.delegateUntilTestEnd(object : NavigationDelegate {
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ return GeckoResult.allow()
+ }
+ })
+
+ thread {
+ // Make sure we're running in a thread without a Looper.
+ assertThat(
+ "We should not have a looper.",
+ Looper.myLooper(),
+ equalTo(null),
+ )
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ }
+
+ mainSession.waitUntilCalled(object : ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page loaded successfully", success, equalTo(true))
+ }
+ })
+ }
+
+ @Test
+ fun loadShortDataUriToplevelIndirect() {
+ mainSession.delegateUntilTestEnd(object : NavigationDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ return GeckoResult.allow()
+ }
+
+ @AssertCalled(false)
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ return null
+ }
+ })
+
+ val dataBytes = this.getTestBytes("/assets/www/images/test.gif")
+ val uri = createDataUri(dataBytes, "image/*")
+
+ mainSession.loadTestPath(DATA_URI_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.querySelector('#smallLink').href = \"$uri\"")
+ mainSession.evaluateJS("document.querySelector('#smallLink').click()")
+ mainSession.waitForPageStop()
+ }
+
+ fun createLargeHighEntropyImageDataUri(): String {
+ val desiredMinSize = (2 * 1024 * 1024) + 1
+
+ val width = 768
+ val height = 768
+
+ val bitmap = Bitmap.createBitmap(
+ ThreadLocalRandom.current().ints(width.toLong() * height.toLong()).toArray(),
+ width,
+ height,
+ Bitmap.Config.ARGB_8888,
+ )
+
+ val stream = ByteArrayOutputStream()
+ if (!bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)) {
+ throw Exception("Error compressing PNG")
+ }
+
+ val uri = createDataUri(stream.toByteArray(), "image/png")
+
+ if (uri.length < desiredMinSize) {
+ throw Exception("Test uri is too small, want at least " + desiredMinSize + ", got " + uri.length)
+ }
+
+ return uri
+ }
+
+ @Test
+ fun loadLongDataUriNonToplevel() {
+ val dataUri = createLargeHighEntropyImageDataUri()
+
+ mainSession.delegateUntilTestEnd(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ return GeckoResult.allow()
+ }
+
+ @AssertCalled(false)
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ return null
+ }
+ })
+
+ mainSession.loadTestPath(DATA_URI_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.querySelector('#image').onload = () => { imageLoaded = true; }")
+ mainSession.evaluateJS("document.querySelector('#image').src = \"$dataUri\"")
+ UiThreadUtils.waitForCondition({
+ mainSession.evaluateJS("document.querySelector('#image').complete") as Boolean
+ }, sessionRule.env.defaultTimeoutMillis)
+ mainSession.evaluateJS("if (!imageLoaded) throw imageLoaded")
+ }
+
+ @Test
+ fun bypassLoadUriDelegate() {
+ val testUri = "https://www.mozilla.org"
+
+ mainSession.load(
+ Loader()
+ .uri(testUri)
+ .flags(GeckoSession.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE),
+ )
+ mainSession.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(
+ object : NavigationDelegate {
+ @AssertCalled(false)
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ return null
+ }
+ },
+ )
+ }
+
+ @Test fun goBackFromHistory() {
+ // TODO: Bug 1673954
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+
+ mainSession.waitUntilCalled(object : HistoryDelegate, ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) {
+ assertThat("History should have one entry", state.size, equalTo(1))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should match", title, equalTo("Hello, world!"))
+ }
+ })
+
+ mainSession.loadTestPath(HELLO2_HTML_PATH)
+
+ mainSession.waitUntilCalled(object : HistoryDelegate, NavigationDelegate, ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) {
+ assertThat("History should have two entry", state.size, equalTo(2))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ assertThat("Can go back", canGoBack, equalTo(true))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ assertThat("Title should match", title, equalTo("Hello, world! Again!"))
+ }
+ })
+
+ // goBack will be navigated from history.
+
+ var lastTitle: String? = ""
+ sessionRule.delegateDuringNextWait(object : NavigationDelegate, ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ assertThat("URL should match", url, endsWith(HELLO_HTML_PATH))
+ }
+
+ @AssertCalled
+ override fun onTitleChange(session: GeckoSession, title: String?) {
+ lastTitle = title
+ }
+ })
+
+ mainSession.goBack()
+ sessionRule.waitForPageStop()
+ assertThat("Title should match", lastTitle, equalTo("Hello, world!"))
+ }
+
+ @Test
+ fun loadAndroidAssets() {
+ val assetUri = "resource://android/assets/web_extensions/"
+ mainSession.loadUri(assetUri)
+
+ mainSession.waitUntilCalled(object : ProgressDelegate {
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ assertThat("Page loaded successfully", success, equalTo(true))
+ }
+ })
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt
new file mode 100644
index 0000000000..4fdac68d93
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt
@@ -0,0 +1,35 @@
+/* -*- 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.Matchers.equalTo
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class NimbusTest : BaseSessionTest() {
+
+ @Test
+ fun withPdfJS() {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_PATH)
+
+ sessionRule.waitUntilCalled(object : ContentDelegate {
+ override fun onGetNimbusFeature(session: GeckoSession, featureId: String): JSONObject? {
+ assertThat(
+ "Feature id should match",
+ featureId,
+ equalTo("pdfjs"),
+ )
+ return null
+ }
+ })
+ }
+}
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..335535bbb4
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt
@@ -0,0 +1,145 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+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.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+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 : PermissionDelegate {
+ override fun onContentPermissionRequest(session: GeckoSession, perm: PermissionDelegate.ContentPermission): GeckoResult<Int>? {
+ assertThat("Should grant DESKTOP_NOTIFICATIONS permission", perm.permission, equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+ return GeckoResult.fromValue(PermissionDelegate.ContentPermission.VALUE_ALLOW)
+ }
+ })
+ }
+
+ 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 notificationResult = GeckoResult<Void>()
+ var notificationShown: WebNotification? = null
+
+ sessionRule.delegateDuringNextWait(object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationShown = notification
+ notificationResult.complete(null)
+ }
+ })
+ mainSession.evaluateJS("showNotification()")
+ sessionRule.waitForResult(notificationResult)
+ notificationShown!!.click()
+ }
+
+ @Test
+ @NullDelegate(ServiceWorkerDelegate::class)
+ fun openWindowNullDelegate() {
+ sessionRule.delegateUntilTestEnd(object : ContentDelegate, NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ // 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.delegateUntilTestEnd(object : ContentDelegate, NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ // we should not open the target url
+ assertThat("URL should notmatch", url, not(createTestUrl(OPEN_WINDOW_TARGET_PATH)))
+ }
+ })
+ openPageClickNotification()
+ sessionRule.waitUntilCalled(object : ServiceWorkerDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenWindow(url: String): GeckoResult<GeckoSession> {
+ ThreadUtils.assertOnUiThread()
+ return GeckoResult.fromValue(null)
+ }
+ })
+ }
+
+ @Test
+ fun openWindowSameSession() {
+ sessionRule.delegateUntilTestEnd(object : ServiceWorkerDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenWindow(url: String): GeckoResult<GeckoSession> {
+ ThreadUtils.assertOnUiThread()
+ return GeckoResult.fromValue(mainSession)
+ }
+ })
+ openPageClickNotification()
+ sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ 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.delegateUntilTestEnd(object : ServiceWorkerDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenWindow(url: String): GeckoResult<GeckoSession> {
+ ThreadUtils.assertOnUiThread()
+ targetSession = sessionRule.createOpenSession()
+ return GeckoResult.fromValue(targetSession)
+ }
+ })
+ openPageClickNotification()
+ sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ 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"))
+ }
+ })
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt
new file mode 100644
index 0000000000..26ff365659
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt
@@ -0,0 +1,311 @@
+/* -*- 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.content.pm.ActivityInfo
+import android.content.res.Configuration
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.OrientationController
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class OrientationDelegateTest : BaseSessionTest() {
+ val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+
+ @get:Rule
+ override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ @Before
+ fun setup() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.screenorientation.allow-lock" to 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 : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
+ assertThat("Div went fullscreen", fullScreen, equalTo(true))
+ }
+ })
+ promise.value
+ }
+
+ private fun lockPortrait() {
+ val promise = mainSession.evaluatePromiseJS("screen.orientation.lock('portrait-primary')")
+ sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate {
+ @AssertCalled(count = 1)
+ override fun onOrientationLock(aOrientation: Int): GeckoResult<AllowOrDeny> {
+ assertThat(
+ "The orientation should be portrait",
+ aOrientation,
+ equalTo(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT),
+ )
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = aOrientation
+ }
+ return GeckoResult.allow()
+ }
+ })
+ sessionRule.runtime.orientationChanged(Configuration.ORIENTATION_PORTRAIT)
+ promise.value
+ // Remove previous delegate
+ mainSession.waitForRoundTrip()
+ }
+
+ private fun lockLandscape() {
+ val promise = mainSession.evaluatePromiseJS("screen.orientation.lock('landscape-primary')")
+ sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate {
+ @AssertCalled(count = 1)
+ override fun onOrientationLock(aOrientation: Int): GeckoResult<AllowOrDeny> {
+ assertThat(
+ "The orientation should be landscape",
+ aOrientation,
+ equalTo(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE),
+ )
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = aOrientation
+ }
+ return GeckoResult.allow()
+ }
+ })
+ sessionRule.runtime.orientationChanged(Configuration.ORIENTATION_LANDSCAPE)
+ promise.value
+ // Remove previous delegate
+ mainSession.waitForRoundTrip()
+ }
+
+ @Test fun orientationLock() {
+ goFullscreen()
+ activityRule.scenario.onActivity { activity ->
+ // If the orientation is landscape, lock to portrait and wait for delegate. If portrait, lock to landscape instead.
+ if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) {
+ lockPortrait()
+ } else if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
+ lockLandscape()
+ }
+ }
+ }
+
+ @Test fun orientationUnlock() {
+ goFullscreen()
+ mainSession.evaluateJS("screen.orientation.unlock()")
+ sessionRule.waitUntilCalled(object : OrientationController.OrientationDelegate {
+ @AssertCalled(count = 1)
+ override fun onOrientationUnlock() {
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+ })
+ }
+
+ @Test fun orientationLockedAlready() {
+ goFullscreen()
+ // Lock to landscape twice to verify successful locking with existing lock
+ lockLandscape()
+ lockLandscape()
+ }
+
+ @Test fun orientationLockedExistingOrientation() {
+ goFullscreen()
+
+ val promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ if (screen.orientation.type == "landscape-primary") {
+ resolve();
+ }
+ screen.orientation.addEventListener("change", e => {
+ if (screen.orientation.type == "landscape-primary") {
+ resolve();
+ }
+ }, { once: true });
+ })
+ """.trimIndent(),
+ )
+
+ // Lock to landscape twice to verify successful locking to existing orientation
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ // Wait for orientation change by activity.requestedOrientation.
+ promise.value
+ lockLandscape()
+ }
+
+ @Test(expected = GeckoSessionTestRule.RejectedPromiseException::class)
+ fun orientationLockNoFullscreen() {
+ // Verify if fullscreen pre-lock conditions are not met, a rejected promise is returned.
+ mainSession.loadTestPath(FULLSCREEN_PATH)
+ mainSession.waitForPageStop()
+ mainSession.evaluateJS("screen.orientation.lock('landscape-primary')")
+ }
+
+ @Test fun orientationLockUnlock() {
+ goFullscreen()
+
+ val promise = mainSession.evaluatePromiseJS("screen.orientation.lock('landscape-primary')")
+ sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate {
+ @AssertCalled(count = 1)
+ override fun onOrientationLock(aOrientation: Int): GeckoResult<AllowOrDeny> {
+ assertThat(
+ "The orientation value is as expected",
+ aOrientation,
+ equalTo(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE),
+ )
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = aOrientation
+ }
+ return GeckoResult.allow()
+ }
+ })
+ sessionRule.runtime.orientationChanged(Configuration.ORIENTATION_LANDSCAPE)
+ promise.value
+ // Remove previous delegate
+ mainSession.waitForRoundTrip()
+
+ // after locking to orientation landscape, unlock to default
+ mainSession.evaluateJS("screen.orientation.unlock()")
+ sessionRule.waitUntilCalled(object : OrientationController.OrientationDelegate {
+ @AssertCalled(count = 1)
+ override fun onOrientationUnlock() {
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+ })
+ }
+
+ @Test fun orientationLockUnsupported() {
+ // If no delegate, orientation.lock must throws NotSupportedError
+ goFullscreen()
+
+ val promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(r => {
+ screen.orientation.lock('landscape-primary')
+ .then(() => r("successful"))
+ .catch(e => r(e.name))
+ })
+ """.trimIndent(),
+ )
+
+ assertThat(
+ "The operation must throw NotSupportedError",
+ promise.value,
+ equalTo("NotSupportedError"),
+ )
+
+ val promise2 = mainSession.evaluatePromiseJS(
+ """
+ new Promise(r => {
+ screen.orientation.lock(screen.orientation.type)
+ .then(() => r("successful"))
+ .catch(e => r(e.name))
+ })
+ """.trimIndent(),
+ )
+
+ assertThat(
+ "The operation must throw NotSupportedError even if same orientation",
+ promise2.value,
+ equalTo("NotSupportedError"),
+ )
+ }
+
+ @WithDisplay(width = 300, height = 200)
+ @Test
+ fun orientationUnlockByExitFullscreen() {
+ goFullscreen()
+ activityRule.scenario.onActivity { activity ->
+ // If the orientation is landscape, lock to portrait and wait for delegate. If portrait, lock to landscape instead.
+ if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) {
+ lockPortrait()
+ } else if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
+ lockLandscape()
+ }
+ }
+
+ val promise = mainSession.evaluatePromiseJS("document.exitFullscreen()")
+ sessionRule.waitUntilCalled(object : ContentDelegate, OrientationController.OrientationDelegate {
+ @AssertCalled(count = 1)
+ override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
+ assertThat("Exited fullscreen", fullScreen, equalTo(false))
+ }
+
+ @AssertCalled(count = 1)
+ override fun onOrientationUnlock() {
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+ })
+ promise.value
+ }
+
+ @WithDisplay(width = 200, height = 300)
+ @Test
+ fun orientationNatural() {
+ goFullscreen()
+
+ // Set orientation to landscape since natural is portrait.
+ var promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ if (screen.orientation.type == "landscape-primary") {
+ resolve();
+ }
+ screen.orientation.addEventListener("change", e => {
+ if (screen.orientation.type == "landscape-primary") {
+ resolve();
+ }
+ }, { once: true });
+ })
+ """.trimIndent(),
+ )
+
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ // Wait for orientation change by activity.requestedOrientation.
+ promise.value
+
+ sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate {
+ @AssertCalled(count = 1)
+ override fun onOrientationLock(aOrientation: Int): GeckoResult<AllowOrDeny> {
+ assertThat(
+ "The orientation should be portrait",
+ aOrientation,
+ equalTo(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT),
+ )
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = aOrientation
+ }
+ return GeckoResult.allow()
+ }
+ })
+ promise = mainSession.evaluatePromiseJS("screen.orientation.lock('natural')")
+ promise.value
+ // Remove previous delegate
+ mainSession.waitForRoundTrip()
+ }
+}
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..e40d047558
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt
@@ -0,0 +1,613 @@
+package org.mozilla.geckoview.test
+
+import android.os.SystemClock
+import android.view.MotionEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.PanZoomController
+import org.mozilla.geckoview.ScreenLength
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import kotlin.math.roundToInt
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PanZoomControllerTest : BaseSessionTest() {
+ private val errorEpsilon = 3.0
+ private val scrollWaitTimeout = 10000.0 // 10 seconds
+
+ private fun setupDocument(documentPath: String) {
+ mainSession.loadTestPath(documentPath)
+ mainSession.waitForPageStop()
+ mainSession.promiseAllPaintsDone()
+ mainSession.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))
+ mainSession.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))
+ mainSession.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))
+ mainSession.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ mainSession.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))
+ mainSession.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))
+ mainSession.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
+ // Need to round due to dom.InnerSize.rounded=true
+ assertThat(
+ "Visual viewport height equals to window.innerHeight",
+ originalVH.roundToInt(),
+ equalTo(innerHeight.roundToInt()),
+ )
+
+ 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.
+ mainSession.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))
+
+ mainSession.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))
+ mainSession.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode)
+ waitForVerticalScroll(vh, scrollWaitTimeout)
+ mainSession.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.onTouchEventForDetailResult(down)
+ .map { value -> value!!.handledResult() }
+ 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()
+
+ // Non-scrollable page: value is always INPUT_RESULT_UNHANDLED
+
+ // No touch handler
+ 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))
+
+ // Scrollable page: value depends on the presence and type of touch handler
+ setupScroll()
+
+ // No touch handler
+ value = sessionRule.waitForResult(sendDownEvent(50f, 15f))
+ assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED))
+
+ // 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))
+ 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) }
+
+ // Entries are pairs of (filename, pageIsPannable)
+ // Note: "pageIsPannable" means "pannable" in the sense used in
+ // AsyncPanZoomController::ArePointerEventsConsumable().
+ // For example, in iframe_98vh_no_scrollable.html, even though
+ // the page does not have a scroll range, the page is "pannable"
+ // because the dynamic toolbar can be hidden.
+ 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),
+ )
+ }
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun touchActionWithWheelListener() {
+ sessionRule.display?.run { setDynamicToolbarMaxHeight(20) }
+ setupDocument(TOUCH_ACTION_WHEEL_LISTENER_HTML_PATH)
+ var value = sessionRule.waitForResult(sendDownEvent(50f, 50f))
+ assertThat(
+ "The input result should be HANDLED_CONTENT",
+ 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.onTouchEventForDetailResult(down)
+ .map { value -> value!!.handledResult() }
+ 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() {
+ 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_HANDLED),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun touchEventWithXOrigin() {
+ setupDocument(TOUCH_XORIGIN_HTML_PATH)
+
+ // Touch handler with preventDefault
+ val value = sessionRule.waitForResult(sendDownEvent(50f, 45f))
+ assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT))
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt
new file mode 100644
index 0000000000..9693139d9c
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.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 android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.Color.rgb
+import android.graphics.pdf.PdfRenderer
+import android.os.ParcelFileDescriptor
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import org.hamcrest.Matchers.equalTo
+import org.junit.After
+import org.junit.Assert.assertTrue
+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.Autofill
+import org.mozilla.geckoview.GeckoViewPrintDocumentAdapter
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+import java.io.File
+import java.io.InputStream
+import kotlin.math.roundToInt
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfCreationTest : BaseSessionTest() {
+ private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+ var deviceHeight = 0
+ var deviceWidth = 0
+ var scaledHeight = 0
+ var scaledWidth = 12
+
+ @get:Rule
+ override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ @Before
+ fun setup() {
+ activityRule.scenario.onActivity {
+ it.view.setSession(mainSession)
+ deviceHeight = it.resources.displayMetrics.heightPixels
+ deviceWidth = it.resources.displayMetrics.widthPixels
+ scaledHeight = (scaledWidth * (deviceHeight / deviceWidth.toDouble())).roundToInt()
+ }
+ }
+
+ @After
+ fun cleanup() {
+ activityRule.scenario.onActivity {
+ it.view.releaseSession()
+ }
+ }
+
+ private fun createFileDescriptor(pdfInputStream: InputStream): ParcelFileDescriptor {
+ val file = File.createTempFile("temp", null)
+ pdfInputStream.use { input ->
+ file.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+
+ private fun pdfToBitmap(pdfInputStream: InputStream): ArrayList<Bitmap>? {
+ val bitmaps: ArrayList<Bitmap> = ArrayList()
+ try {
+ val pdfRenderer = PdfRenderer(createFileDescriptor(pdfInputStream))
+ for (pageNo in 0 until pdfRenderer.pageCount) {
+ val page = pdfRenderer.openPage(pageNo)
+ var bitmap = Bitmap.createBitmap(deviceWidth, deviceHeight, Bitmap.Config.ARGB_8888)
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
+ bitmaps.add(bitmap)
+ page.close()
+ }
+ pdfRenderer.close()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return bitmaps
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun singleColorPdf() {
+ activityRule.scenario.onActivity {
+ mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH)
+ mainSession.waitForPageStop()
+ val pdfInputStream = mainSession.saveAsPdf()
+ sessionRule.waitForResult(pdfInputStream).let {
+ val bitmap = pdfToBitmap(it)!![0]
+ val scaled = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, false)
+ val centerPixel = scaled.getPixel(scaledWidth / 2, scaledHeight / 2)
+ val orange = rgb(255, 113, 57)
+ assertTrue("The PDF orange color matches.", centerPixel == orange)
+ }
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun rgbColorsPdf() {
+ activityRule.scenario.onActivity {
+ mainSession.loadTestPath(COLOR_GRID_HTML_PATH)
+ mainSession.waitForPageStop()
+ val pdfInputStream = mainSession.saveAsPdf()
+ sessionRule.waitForResult(pdfInputStream).let {
+ val bitmap = pdfToBitmap(it)!![0]
+ val scaled = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, false)
+ val redPixel = scaled.getPixel(2, scaledHeight / 2)
+ assertTrue("The PDF red color matches.", redPixel == Color.RED)
+ val greenPixel = scaled.getPixel(scaledWidth / 2, scaledHeight / 2)
+ assertTrue("The PDF green color matches.", greenPixel == Color.GREEN)
+ val bluePixel = scaled.getPixel(scaledWidth - 2, scaledHeight / 2)
+ assertTrue("The PDF blue color matches.", bluePixel == Color.BLUE)
+ val doPixelsMatch = (
+ redPixel == Color.RED &&
+ greenPixel == Color.GREEN &&
+ bluePixel == Color.BLUE
+ )
+ assertTrue("The PDF generated RGB colors.", doPixelsMatch)
+ }
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun makeTempPdfFileTest() {
+ activityRule.scenario.onActivity { activity ->
+ mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH)
+ mainSession.waitForPageStop()
+ val pdfInputStream = mainSession.saveAsPdf()
+ sessionRule.waitForResult(pdfInputStream).let { stream ->
+ val file = GeckoViewPrintDocumentAdapter.makeTempPdfFile(stream, activity)!!
+ assertTrue("PDF File exists.", file.exists())
+ assertTrue("PDF File is not empty.", file.length() > 0L)
+ file.delete()
+ }
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun saveAPdfDocument() {
+ activityRule.scenario.onActivity {
+ mainSession.loadTestPath(HELLO_PDF_WORLD_PDF_PATH)
+ mainSession.waitForPageStop()
+ val pdfInputStream = mainSession.saveAsPdf()
+ val originalBytes = getTestBytes(HELLO_PDF_WORLD_PDF_PATH)
+ sessionRule.waitForResult(pdfInputStream).let {
+ assertThat("The PDF File must the same as the original one.", it!!.readBytes(), equalTo(originalBytes))
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt
new file mode 100644
index 0000000000..e0211dd07c
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt
@@ -0,0 +1,30 @@
+/* -*- 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.Matchers.equalTo
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PdfSaveTest : BaseSessionTest() {
+
+ @Test fun savePdf() {
+ mainSession.loadTestPath(TRACEMONKEY_PDF_PATH)
+ mainSession.waitForPageStop()
+
+ val response = sessionRule.waitForResult(mainSession.pdfFileSaver.save())
+ val originalBytes = getTestBytes(TRACEMONKEY_PDF_PATH)
+ val filename = TRACEMONKEY_PDF_PATH.substringAfterLast("/")
+
+ assertThat("Check the response uri.", response.uri.substringAfterLast("/"), equalTo(filename))
+ assertThat("Check the response content-type.", response.headers.get("content-type"), equalTo("application/pdf"))
+ assertThat("Check the response filename.", response.headers.get("Content-disposition"), equalTo("attachment; filename=\"" + filename + "\""))
+ assertThat("Check that bytes arrays are the same.", response.body?.readBytes(), equalTo(originalBytes))
+ }
+}
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..9ab2d2515f
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt
@@ -0,0 +1,1132 @@
+/* -*- 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.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.LocationManager
+import android.os.Build
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.json.JSONArray
+import org.junit.Assert.fail
+import org.junit.Assume.assumeThat
+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.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaCallback
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource
+import org.mozilla.geckoview.GeckoSessionSettings
+import org.mozilla.geckoview.StorageController.ClearFlags
+import org.mozilla.geckoview.test.TrackingPermissionService.TrackingPermissionInstance
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PermissionDelegateTest : BaseSessionTest() {
+ private val targetContext
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+ 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" == Build.DEVICE || Build.DEVICE.startsWith("generic_")
+ }
+
+ private val storageController
+ get() = sessionRule.runtime.storageController
+
+ @Test fun media() {
+ // TODO: needs bug 1700243
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ 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 : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onMediaPermissionRequest(
+ session: GeckoSession,
+ uri: String,
+ video: Array<out MediaSource>?,
+ audio: Array<out MediaSource>?,
+ callback: 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.
+ val code = if (isEmulator()) {
+ """this.stream = window.navigator.mediaDevices.getUserMedia({
+ video: { width: 320, height: 240, frameRate: 10 },
+ });"""
+ } else {
+ """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 : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onMediaPermissionRequest(
+ session: GeckoSession,
+ uri: String,
+ video: Array<out MediaSource>?,
+ audio: Array<out MediaSource>?,
+ callback: 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 = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ // Set location for test
+ sessionRule.setPrefsUntilTestEnd(mapOf("geo.provider.testing" to false))
+ var context = InstrumentationRegistry.getInstrumentation().targetContext
+ var locManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+ var locProvider = sessionRule.MockLocationProvider(
+ locManager,
+ "permissionsLocationProvider",
+ 1.1111,
+ 2.2222,
+ false,
+ )
+ locProvider.postLocation()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ // Ensure the content permission is asked first, before the Android permission.
+ @AssertCalled(count = 1, order = [1])
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_GEOLOCATION),
+ )
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW)
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession,
+ permissions: Array<out String>?,
+ callback: 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"),
+ )
+ }
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound = true
+ }
+ }
+
+ assertThat("Geolocation permission should be set to allow", permFound, equalTo(true))
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION &&
+ perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Geolocation permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ locProvider.removeMockLocationProvider()
+ }
+
+ @Test fun geolocation_reject() {
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ return GeckoResult.fromValue(ContentPermission.VALUE_DENY)
+ }
+
+ @AssertCalled(count = 0)
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession,
+ permissions: Array<out String>?,
+ callback: 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))
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_DENY
+ ) {
+ permFound = true
+ }
+ }
+
+ assertThat("Geolocation permission should be set to allow", permFound, equalTo(true))
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION &&
+ perm.value == ContentPermission.VALUE_DENY
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Geolocation permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @ClosedSessionAtStart
+ @Test
+ fun trackingProtection() {
+ // Tests that we get a tracking protection permission for every load, we
+ // can set the value of the permission and that the permission persists
+ // across sessions
+ trackingProtection(privateBrowsing = false, permanent = true)
+ }
+
+ @ClosedSessionAtStart
+ @Test
+ fun trackingProtectionPrivateBrowsing() {
+ // Tests that we get a tracking protection permission for every load, we
+ // can set the value of the permission in private browsing and that the
+ // permission does not persists across private sessions
+ trackingProtection(privateBrowsing = true, permanent = false)
+ }
+
+ @ClosedSessionAtStart
+ @Test
+ fun trackingProtectionPrivateBrowsingPermanent() {
+ // Tests that we get a tracking protection permission for every load, we
+ // can set the value of the permission permanently in private browsing
+ // and that the permanent permission _does_ persists across private sessions
+ trackingProtection(privateBrowsing = true, permanent = true)
+ }
+
+ private fun trackingProtection(privateBrowsing: Boolean, permanent: Boolean) {
+ // Make sure we start with a clean slate
+ storageController.clearDataFromHost(TEST_HOST, ClearFlags.PERMISSIONS)
+
+ assertThat(
+ "Non-permanent only makes sense with private browsing " +
+ "(because non-private browsing exceptions are always permanent",
+ permanent || privateBrowsing,
+ equalTo(true),
+ )
+
+ val runtime0 = TrackingPermissionInstance.start(
+ targetContext,
+ temporaryProfile.get(),
+ privateBrowsing,
+ )
+
+ sessionRule.waitForResult(runtime0.loadTestPath(TRACKERS_PATH))
+ var permission = sessionRule.waitForResult(runtime0.trackingPermission)
+
+ assertThat(
+ "Permission value should start at DENY",
+ permission,
+ equalTo(ContentPermission.VALUE_DENY),
+ )
+
+ if (privateBrowsing && permanent) {
+ runtime0.setPrivateBrowsingPermanentTrackingPermission(
+ ContentPermission.VALUE_ALLOW,
+ )
+ } else {
+ runtime0.setTrackingPermission(ContentPermission.VALUE_ALLOW)
+ }
+
+ sessionRule.waitForResult(runtime0.reload())
+
+ permission = sessionRule.waitForResult(runtime0.trackingPermission)
+ assertThat(
+ "Permission value should be ALLOW after setting",
+ permission,
+ equalTo(ContentPermission.VALUE_ALLOW),
+ )
+
+ sessionRule.waitForResult(runtime0.quit())
+
+ // Restart the runtime and verifies that the value is still stored
+ val runtime1 = TrackingPermissionInstance.start(
+ targetContext,
+ temporaryProfile.get(),
+ privateBrowsing,
+ )
+
+ sessionRule.waitForResult(runtime1.loadTestPath(TRACKERS_PATH))
+
+ val trackingPermission = sessionRule.waitForResult(runtime1.trackingPermission)
+ assertThat(
+ "Tracking permissions should persist only if permanent",
+ trackingPermission,
+ equalTo(
+ when {
+ permanent -> ContentPermission.VALUE_ALLOW
+ else -> ContentPermission.VALUE_DENY
+ },
+ ),
+ )
+
+ sessionRule.waitForResult(runtime1.quit())
+ }
+
+ private fun assertTrackingProtectionPermission(value: Int?) {
+ var found = false
+ mainSession.waitUntilCalled(object : NavigationDelegate {
+ @AssertCalled
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<ContentPermission>,
+ ) {
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_TRACKING) {
+ if (value != null) {
+ assertThat(
+ "Value should match",
+ perm.value,
+ equalTo(value),
+ )
+ }
+ found = true
+ }
+ }
+ }
+ })
+
+ assertThat(
+ "Permission should have been found if expected",
+ found,
+ equalTo(value != null),
+ )
+ }
+
+ // Tests that all pages have a PERMISSION_TRACKING permission,
+ // except for pages that belong to Gecko like about:blank or about:config.
+ @Test fun trackingProtectionPermissionOnAllPages() {
+ val settings = sessionRule.runtime.settings
+ val aboutConfigEnabled = settings.aboutConfigEnabled
+ settings.aboutConfigEnabled = true
+
+ mainSession.loadUri("about:config")
+ assertTrackingProtectionPermission(null)
+
+ settings.aboutConfigEnabled = aboutConfigEnabled
+
+ mainSession.loadUri("about:blank")
+ assertTrackingProtectionPermission(null)
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ assertTrackingProtectionPermission(ContentPermission.VALUE_DENY)
+ }
+
+ @Test fun notification() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION),
+ )
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should be granted",
+ result as String,
+ equalTo("granted"),
+ )
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Notification permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ val result2 = mainSession.waitForJS("Notification.permission")
+
+ assertThat(
+ "Permission should be granted",
+ result2 as String,
+ equalTo("granted"),
+ )
+ }
+
+ @Ignore("disable test for frequently failing Bug 1542525")
+ @Test
+ fun notification_reject() {
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ return GeckoResult.fromValue(ContentPermission.VALUE_DENY)
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should not be granted",
+ result as String,
+ equalTo("denied"),
+ )
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_DENY
+ ) {
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ perm.value == ContentPermission.VALUE_DENY
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Notification permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @Test
+ fun autoplayReject() {
+ // Bug 1810736
+ assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false))
+
+ // 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 : PermissionDelegate {
+ @AssertCalled(count = 2)
+ override fun onContentPermissionRequest(session: GeckoSession, perm: ContentPermission):
+ GeckoResult<Int> {
+ val expectedType = if (sessionRule.currentCall.counter == 1) PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE else PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE
+ assertThat("Type should match", perm.permission, equalTo(expectedType))
+ return GeckoResult.fromValue(ContentPermission.VALUE_DENY)
+ }
+ })
+ }
+
+ @Test
+ fun contextId() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION),
+ )
+ assertThat("Context ID should match", perm.contextId, equalTo(mainSession.settings.contextId))
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should be granted",
+ result as String,
+ equalTo("granted"),
+ )
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url, false))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Notification permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ val session2 = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder()
+ .contextId("foo")
+ .build(),
+ )
+
+ session2.loadUri(url)
+ session2.waitForPageStop()
+
+ session2.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION),
+ )
+ assertThat(
+ "Context ID should match",
+ perm.contextId,
+ equalTo(session2.settings.contextId),
+ )
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ val result2 = session2.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should be granted",
+ result2 as String,
+ equalTo("granted"),
+ )
+
+ val perms2 = sessionRule.waitForResult(storageController.getPermissions(url, false))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ permFound = false
+ for (perm in perms2) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ session2.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ perm.value == ContentPermission.VALUE_ALLOW &&
+ perm.contextId == session2.settings.contextId
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Notification permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ session2.reload()
+ session2.waitForPageStop()
+ }
+
+ @Test fun setPermissionAllow() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION),
+ )
+ return GeckoResult.fromValue(ContentPermission.VALUE_DENY)
+ }
+ })
+ mainSession.waitForJS("Notification.requestPermission()")
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ var notificationPerm: ContentPermission? = null
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_DENY
+ ) {
+ notificationPerm = perm
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ storageController.setPermission(
+ notificationPerm!!,
+ ContentPermission.VALUE_ALLOW,
+ )
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Notification permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ val result = mainSession.waitForJS("Notification.permission")
+
+ assertThat(
+ "Permission should be granted",
+ result as String,
+ equalTo("granted"),
+ )
+ }
+
+ @Test fun setPermissionDeny() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION),
+ )
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should be granted",
+ result as String,
+ equalTo("granted"),
+ )
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ var notificationPerm: ContentPermission? = null
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ notificationPerm = perm
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ storageController.setPermission(
+ notificationPerm!!,
+ ContentPermission.VALUE_DENY,
+ )
+
+ mainSession.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ var permFound2 = false
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ perm.value == ContentPermission.VALUE_DENY
+ ) {
+ permFound2 = true
+ }
+ }
+ assertThat("Notification permission must be present on refresh", permFound2, equalTo(true))
+ }
+ })
+ mainSession.reload()
+ mainSession.waitForPageStop()
+
+ val result2 = mainSession.waitForJS("Notification.permission")
+
+ assertThat(
+ "Permission should be denied",
+ result2 as String,
+ equalTo("denied"),
+ )
+ }
+
+ @Test fun setPermissionPrompt() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION),
+ )
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should be granted",
+ result as String,
+ equalTo("granted"),
+ )
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ var notificationPerm: ContentPermission? = null
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ notificationPerm = perm
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ storageController.setPermission(
+ notificationPerm!!,
+ ContentPermission.VALUE_PROMPT,
+ )
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ return GeckoResult.fromValue(ContentPermission.VALUE_PROMPT)
+ }
+ })
+
+ val result2 = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should be default",
+ result2 as String,
+ equalTo("default"),
+ )
+ }
+
+ @Test fun permissionJsonConversion() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false))
+ val url = createTestUrl(HELLO_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ @AssertCalled(count = 1)
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: ContentPermission,
+ ):
+ GeckoResult<Int> {
+ assertThat("URI should match", perm.uri, endsWith(url))
+ assertThat(
+ "Type should match",
+ perm.permission,
+ equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION),
+ )
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+
+ assertThat(
+ "Permission should be granted",
+ result as String,
+ equalTo("granted"),
+ )
+
+ val perms = sessionRule.waitForResult(storageController.getPermissions(url))
+
+ assertThat("Permissions should not be null", perms, notNullValue())
+ var permFound = false
+ var notificationPerm: ContentPermission? = null
+ for (perm in perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION &&
+ url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW
+ ) {
+ notificationPerm = perm
+ permFound = true
+ }
+ }
+
+ assertThat("Notification permission should be set to allow", permFound, equalTo(true))
+
+ val jsonPerm = notificationPerm?.toJson()
+ assertThat("JSON export should not be null", jsonPerm, notNullValue())
+
+ val importedPerm = ContentPermission.fromJson(jsonPerm!!)
+ assertThat("JSON import should not be null", importedPerm, notNullValue())
+
+ assertThat("URIs should match", importedPerm?.uri, equalTo(notificationPerm?.uri))
+ assertThat("Types should match", importedPerm?.permission, equalTo(notificationPerm?.permission))
+ assertThat("Values should match", importedPerm?.value, equalTo(notificationPerm?.value))
+ assertThat("Context IDs should match", importedPerm?.contextId, equalTo(notificationPerm?.contextId))
+ assertThat("Private mode should match", importedPerm?.privateMode, equalTo(notificationPerm?.privateMode))
+ }
+
+ // @Test fun persistentStorage() {
+ // mainSession.loadTestPath(HELLO_HTML_PATH)
+ // mainSession.waitForPageStop()
+
+ // // Persistent storage can be rejected
+ // mainSession.delegateDuringNextWait(object : PermissionDelegate {
+ // @AssertCalled(count = 1)
+ // override fun onContentPermissionRequest(
+ // session: GeckoSession, uri: String?, type: Int,
+ // callback: 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 : 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: PermissionDelegate.Callback) {
+ // assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
+ // assertThat("Type should match", type,
+ // equalTo(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 : PermissionDelegate {
+ // @AssertCalled(count = 1)
+ // override fun onContentPermissionRequest(
+ // session: GeckoSession, uri: String?, type: Int,
+ // callback: 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/PrintDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt
new file mode 100644
index 0000000000..2e9bc8f135
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt
@@ -0,0 +1,255 @@
+/* -*- 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.UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.Color.rgb
+import android.os.Handler
+import android.os.Looper
+import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SCROLLED
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.CoreMatchers.containsString
+import org.junit.After
+import org.junit.Assert.assertTrue
+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.Autofill
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.PrintDelegate
+import org.mozilla.geckoview.GeckoView.ActivityContextDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+import kotlin.math.roundToInt
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PrintDelegateTest : BaseSessionTest() {
+ private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+ private var deviceHeight = 0
+ private var deviceWidth = 0
+ private var scaledHeight = 0
+ private var scaledWidth = 12
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val uiAutomation = instrumentation.getUiAutomation(FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES)
+
+ @get:Rule
+ override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ @Before
+ fun setup() {
+ activityRule.scenario.onActivity {
+ class PrintTestActivityDelegate : ActivityContextDelegate {
+ override fun getActivityContext(): Context {
+ return it
+ }
+ }
+ // An activity delegate is required for printing
+ it.view.activityContextDelegate = PrintTestActivityDelegate()
+ deviceHeight = it.resources.displayMetrics.heightPixels
+ deviceWidth = it.resources.displayMetrics.widthPixels
+ scaledHeight = (scaledWidth * (deviceHeight / deviceWidth.toDouble())).roundToInt()
+ }
+ }
+
+ @After
+ fun cleanup() {
+ activityRule.scenario.onActivity {
+ uiAutomation.setOnAccessibilityEventListener {}
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun printDelegateTest() {
+ activityRule.scenario.onActivity {
+ var delegateCalled = 0
+ sessionRule.delegateUntilTestEnd(object : PrintDelegate {
+ @AssertCalled(count = 1)
+ override fun onPrint(session: GeckoSession) {
+ delegateCalled++
+ }
+ })
+ mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH)
+ mainSession.waitForPageStop()
+ mainSession.printPageContent()
+ assertTrue("Android print delegate called once.", delegateCalled == 1)
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun windowDotPrintAvailableTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true))
+ activityRule.scenario.onActivity {
+ mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH)
+ mainSession.waitForPageStop()
+ val response = mainSession.waitForJS("window.print();")
+ assertTrue("Window.print(); is available.", response == null)
+ }
+ }
+
+ // Returns the center pixel color of the the print preview's screenshot
+ private fun printCenterPixelColor(): GeckoResult<Int> {
+ val pixelResult = GeckoResult<Int>()
+ // Listening for Android Print Activity
+ uiAutomation.setOnAccessibilityEventListener { event ->
+ if (event.packageName == "com.android.printspooler" &&
+ event.eventType == TYPE_VIEW_SCROLLED
+ ) {
+ uiAutomation.setOnAccessibilityEventListener {}
+ // Delaying the screenshot to give time for preview to load
+ Handler(Looper.getMainLooper()).postDelayed({
+ val bitmap = uiAutomation.takeScreenshot()
+ val scaled = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, false)
+ pixelResult.complete(scaled.getPixel(scaledWidth / 2, scaledHeight / 2))
+ }, 1500)
+ }
+ }
+ return pixelResult
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun printPreviewRendered() {
+ activityRule.scenario.onActivity { activity ->
+ // CSS rules render this blue on screen and orange on print
+ mainSession.loadTestPath(PRINT_CONTENT_CHANGE)
+ mainSession.waitForPageStop()
+ // Setting to the default delegate (test rules changed it)
+ mainSession.printDelegate = activity.view.printDelegate
+ mainSession.printPageContent()
+ val orange = rgb(255, 113, 57)
+ val centerPixel = printCenterPixelColor()
+ assertTrue(
+ "Android print opened and rendered.",
+ sessionRule.waitForResult(centerPixel) == orange,
+ )
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun basicWindowDotPrintTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true))
+ activityRule.scenario.onActivity { activity ->
+ // CSS rules render this blue on screen and orange on print
+ mainSession.loadTestPath(PRINT_CONTENT_CHANGE)
+ mainSession.waitForPageStop()
+ // Setting to the default delegate (test rules changed it)
+ mainSession.printDelegate = activity.view.printDelegate
+ mainSession.evaluateJS("window.print();")
+ val centerPixel = printCenterPixelColor()
+ val orange = rgb(255, 113, 57)
+ assertTrue(
+ "Android print opened and rendered.",
+ sessionRule.waitForResult(centerPixel) == orange,
+ )
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun statusWindowDotPrintTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true))
+ activityRule.scenario.onActivity { activity ->
+ // CSS rules render this blue on screen and orange on print
+ mainSession.loadTestPath(PRINT_CONTENT_CHANGE)
+ mainSession.waitForPageStop()
+ // Setting to the default delegate (test rules changed it)
+ mainSession.printDelegate = activity.view.printDelegate
+ mainSession.evaluateJS("window.print()")
+ val centerPixel = printCenterPixelColor()
+ val orange = rgb(255, 113, 57)
+ assertTrue(
+ "Android print opened and rendered.",
+ sessionRule.waitForResult(centerPixel) == orange,
+ )
+ var didCatch = false
+ try {
+ mainSession.evaluateJS("window.print();")
+ } catch (e: GeckoSessionTestRule.RejectedPromiseException) {
+ assertThat(
+ "Print status context reported.",
+ e.message,
+ containsString("Window.print: No browsing context"),
+ )
+ didCatch = true
+ }
+ assertTrue("Did show print status.", didCatch)
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun staticContextWindowDotPrintTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true))
+ activityRule.scenario.onActivity { activity ->
+ // CSS rules render this blue on screen and orange on print
+ // Print button removes content after printing to test if it froze a static page for printing
+ mainSession.loadTestPath(PRINT_CONTENT_CHANGE)
+ mainSession.waitForPageStop()
+ // Setting to the default delegate (test rules changed it)
+ mainSession.printDelegate = activity.view.printDelegate
+ mainSession.evaluateJS("document.getElementById('print-button').click();")
+ val centerPixel = printCenterPixelColor()
+ val orange = rgb(255, 113, 57)
+ assertTrue(
+ "Android print opened and rendered static page.",
+ sessionRule.waitForResult(centerPixel) == orange,
+ )
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun iframeWindowDotPrintTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true))
+ activityRule.scenario.onActivity { activity ->
+ // Main frame CSS rules render red on screen and green on print
+ // iframe CSS rules render blue on screen and orange on print
+ mainSession.loadTestPath(PRINT_IFRAME)
+ mainSession.waitForPageStop()
+ // Setting to the default delegate (test rules changed it)
+ mainSession.printDelegate = activity.view.printDelegate
+ // iframe window.print button
+ mainSession.evaluateJS("document.getElementById('iframe').contentDocument.getElementById('print-button').click();")
+ val centerPixelIframe = printCenterPixelColor()
+ val orange = rgb(255, 113, 57)
+ sessionRule.waitForResult(centerPixelIframe).let { it ->
+ assertTrue("The iframe should not print green. (Printed containing page instead of iframe.)", it != Color.GREEN)
+ assertTrue("Printed the iframe correctly.", it == orange)
+ }
+ }
+ }
+
+ @NullDelegate(Autofill.Delegate::class)
+ @Test
+ fun contentIframeWindowDotPrintTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true))
+ activityRule.scenario.onActivity { activity ->
+ // Main frame CSS rules render red on screen and green on print
+ // iframe CSS rules render blue on screen and orange on print
+ mainSession.loadTestPath(PRINT_IFRAME)
+ mainSession.waitForPageStop()
+ // Setting to the default delegate (test rules changed it)
+ mainSession.printDelegate = activity.view.printDelegate
+ // Main page window.print button
+ mainSession.evaluateJS("document.getElementById('print-button-page').click();")
+ val centerPixelContent = printCenterPixelColor()
+ assertTrue("Printed the main content correctly.", sessionRule.waitForResult(centerPixelContent) == Color.GREEN)
+ }
+ }
+}
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..7df55b1ccb
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt
@@ -0,0 +1,105 @@
+/* -*- 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.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() {
+ mainSession.loadUri("https://example.com")
+ mainSession.waitForPageStop()
+
+ mainSession.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 = mainSession.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/ProfileLockedTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt
new file mode 100644
index 0000000000..7c47ade0f7
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt
@@ -0,0 +1,52 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.CoreMatchers.equalTo
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.test.TestRuntimeService.RuntimeInstance
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ProfileLockedTest : BaseSessionTest() {
+ private val targetContext
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @Test
+ @ClosedSessionAtStart
+ fun profileLocked() {
+ val runtime0 = RuntimeInstance.start(
+ targetContext,
+ TestRuntimeService.instance0::class.java,
+ temporaryProfile.get(),
+ )
+
+ // Start the first runtime and wait until it's ready
+ sessionRule.waitForResult(runtime0.started)
+
+ assertThat("The service should be connected now", runtime0.isConnected, equalTo(true))
+
+ // Now start a _second_ runtime with the same profile folder, this will kill the first
+ // runtime
+ val runtime1 = RuntimeInstance.start(
+ targetContext,
+ TestRuntimeService.instance1::class.java,
+ temporaryProfile.get(),
+ )
+
+ // Wait for the first runtime to disconnect
+ sessionRule.waitForResult(runtime0.disconnected)
+
+ // GeckoRuntime will quit after killing the offending process
+ sessionRule.waitForResult(runtime1.quitted)
+
+ assertThat(
+ "The service shouldn't be connected anymore",
+ runtime0.isConnected,
+ equalTo(false),
+ )
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt
new file mode 100644
index 0000000000..5d7d60ec6d
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt
@@ -0,0 +1,45 @@
+package org.mozilla.geckoview.test
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.BufferedReader
+import java.io.ByteArrayInputStream
+import java.io.InputStreamReader
+import java.util.zip.GZIPInputStream
+
+@RunWith(AndroidJUnit4::class)
+class ProfilerControllerTest : BaseSessionTest() {
+
+ @Test
+ fun startAndStopProfiler() {
+ sessionRule.runtime.profilerController.startProfiler(arrayOf<String>(), arrayOf<String>())
+ val result = sessionRule.runtime.profilerController.stopProfiler()
+ val byteArray = sessionRule.waitForResult(result)
+ val head = (byteArray[0].toInt() and 0xff) or (byteArray[1].toInt() shl 8 and 0xff00)
+ assertThat(
+ "Header of byte array should be the same as the GZIP one",
+ head,
+ equalTo(GZIPInputStream.GZIP_MAGIC),
+ )
+
+ val profileString = StringBuilder()
+ val gzipInputStream = GZIPInputStream(ByteArrayInputStream(byteArray))
+ val bufferedReader = BufferedReader(InputStreamReader(gzipInputStream))
+
+ var line = bufferedReader.readLine()
+ while (line != null) {
+ profileString.append(line)
+ line = bufferedReader.readLine()
+ }
+
+ val json = JSONObject(profileString.toString())
+ assertThat(
+ "profile JSON object must not be empty",
+ json.length(),
+ greaterThan(0),
+ )
+ }
+}
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..2410db66c5
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt
@@ -0,0 +1,582 @@
+/* -*- 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.LargeTest
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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.GeckoSession.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ProgressDelegateTest : BaseSessionTest() {
+
+ fun testProgress(path: String) {
+ mainSession.loadTestPath(path)
+ sessionRule.waitForPageStop()
+
+ var counter = 0
+ var lastProgress = -1
+
+ sessionRule.forCallbacksDuringWait(object :
+ ProgressDelegate,
+ NavigationDelegate {
+ @AssertCalled
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadUri(UNKNOWN_HOST_URI)
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStops(2)
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ mainSession.loadTestPath(HELLO2_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ mainSession.goBack()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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))
+ }
+ })
+
+ mainSession.goForward()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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))
+
+ mainSession.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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))
+
+ mainSession.loadUri("https://mozilla-modern.badssl.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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() {
+ mainSession.loadUri(
+ if (sessionRule.env.isAutomation) {
+ "https://expired.example.com"
+ } else {
+ "https://expired.badssl.com"
+ },
+ )
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : 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 : 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 containsFormData() {
+ val startUri = createTestUrl(SAVE_STATE_PATH)
+ mainSession.loadUri(startUri)
+ sessionRule.waitForPageStop()
+
+ val formData = mainSession.containsFormData()
+ sessionRule.waitForResult(formData).let {
+ assertThat("There should be no form data", it, equalTo(false))
+ }
+
+ mainSession.evaluateJS("document.querySelector('#name').value = 'the name';")
+ mainSession.evaluateJS("document.querySelector('#name').dispatchEvent(new Event('input'));")
+
+ val formData2 = mainSession.containsFormData()
+ sessionRule.waitForResult(formData2).let {
+ assertThat("There should be form data", it, equalTo(true))
+ }
+ }
+
+ @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 : NavigationDelegate {
+ @AssertCalled
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<ContentPermission>,
+ ) {
+ 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 : NavigationDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ assertThat("History should be preserved", url, equalTo(helloUri))
+ }
+ })
+ }
+
+ @WithDisplay(width = 400, height = 400)
+ @Test
+ fun saveAndRestoreState() {
+ // TODO: Bug 1648158
+ // Bug 1662035 - disable to reduce intermittent failures
+ assumeThat(sessionRule.env.isX86, equalTo(false))
+ 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 : NavigationDelegate {
+ @AssertCalled
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<ContentPermission>) {
+ 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 : 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 : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
+ assertThat("Old session state and new should match", sessionState, equalTo(oldState))
+ }
+ })
+ }
+
+ @Test fun nullState() {
+ val stateFromNull: GeckoSession.SessionState? = GeckoSession.SessionState.fromString(null)
+ val nullState: GeckoSession.SessionState? = null
+ assertThat("Null string should result in null state", stateFromNull, equalTo(nullState))
+ }
+
+ @NullDelegate(GeckoSession.HistoryDelegate::class)
+ @Test
+ fun noHistoryDelegateOnSessionStateChange() {
+ // TODO: Bug 1648158
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.waitUntilCalled(object : ProgressDelegate {
+ @AssertCalled(count = 1)
+ override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
+ }
+ })
+ }
+}
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..10ebefb6dd
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt
@@ -0,0 +1,1084 @@
+/* -*- 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.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assert
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.AllowOrDeny
+import org.mozilla.geckoview.Autocomplete
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.GeckoSession.PromptDelegate
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+@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 : PromptDelegate, 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
+ }
+ })
+
+ mainSession.loadTestPath(POPUP_HTML_PATH)
+ sessionRule.waitUntilCalled(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 : PromptDelegate, 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
+ }
+ })
+
+ mainSession.loadTestPath(POPUP_HTML_PATH)
+ sessionRule.waitForPageStop()
+ mainSession.waitForRoundTrip()
+ }
+
+ @Ignore // TODO: Reenable when 1501574 is fixed.
+ @Test
+ fun alertTest() {
+ mainSession.evaluateJS("alert('Alert!');")
+
+ sessionRule.waitUntilCalled(object : 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())
+ }
+ })
+ }
+
+ // This test checks that saved logins are returned to the app when calling onAuthPrompt
+ @Test fun loginStorageHttpAuthWithPassword() {
+ mainSession.loadTestPath("/basic-auth/foo/bar")
+ sessionRule.delegateDuringNextWait(object : Autocomplete.StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<Autocomplete.LoginEntry>>? {
+ return GeckoResult.fromValue(
+ arrayOf(
+ Autocomplete.LoginEntry.Builder()
+ .origin(GeckoSessionTestRule.TEST_ENDPOINT)
+ .formActionOrigin(GeckoSessionTestRule.TEST_ENDPOINT)
+ .httpRealm("Fake Realm")
+ .username("test-username")
+ .password("test-password")
+ .formActionOrigin(null)
+ .guid("test-guid")
+ .build(),
+ ),
+ )
+ }
+ })
+ sessionRule.waitUntilCalled(object : PromptDelegate, Autocomplete.StorageDelegate {
+ @AssertCalled
+ override fun onAuthPrompt(session: GeckoSession, prompt: AuthPrompt): GeckoResult<PromptResponse>? {
+ assertThat(
+ "Saved login should appear here",
+ prompt.authOptions.username,
+ equalTo("test-username"),
+ )
+ assertThat(
+ "Saved login should appear here",
+ prompt.authOptions.password,
+ equalTo("test-password"),
+ )
+ return null
+ }
+ })
+ }
+
+ // This test checks that we store login information submitted through HTTP basic auth
+ // This also tests that the login save prompt gets automatically dismissed if
+ // the login information is incorrect.
+ @Test fun loginStorageHttpAuth() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "signon.rememberSignons" to true,
+ ),
+ )
+ val result = GeckoResult<PromptDelegate.BasePrompt>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ var prompt: PromptDelegate.BasePrompt? = null
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt)
+ }
+ }
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate, Autocomplete.StorageDelegate {
+ @AssertCalled
+ override fun onAuthPrompt(session: GeckoSession, prompt: AuthPrompt): GeckoResult<PromptResponse>? {
+ return GeckoResult.fromValue(prompt.confirm("foo", "bar"))
+ }
+
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<Autocomplete.LoginEntry>>? {
+ return GeckoResult.fromValue(arrayOf())
+ }
+
+ @AssertCalled
+ override fun onLoginSave(
+ session: GeckoSession,
+ request: PromptDelegate.AutocompleteRequest<Autocomplete.LoginSaveOption>,
+ ): GeckoResult<PromptResponse>? {
+ val authInfo = request.options[0].value
+ assertThat("auth matches", authInfo.formActionOrigin, isEmptyOrNullString())
+ assertThat("auth matches", authInfo.httpRealm, equalTo("Fake Realm"))
+ assertThat("auth matches", authInfo.origin, equalTo(GeckoSessionTestRule.TEST_ENDPOINT))
+ assertThat("auth matches", authInfo.username, equalTo("foo"))
+ assertThat("auth matches", authInfo.password, equalTo("bar"))
+ promptInstanceDelegate.prompt = request
+ request.setDelegate(promptInstanceDelegate)
+ return GeckoResult()
+ }
+ })
+
+ mainSession.loadTestPath("/basic-auth/foo/bar")
+
+ // The server we try to hit will always reject the login so we should
+ // get a request to reauth which should dismiss the prompt
+ val actualPrompt = sessionRule.waitForResult(result)
+
+ assertThat("Prompt object should match", actualPrompt, equalTo(promptInstanceDelegate.prompt))
+ }
+
+ @Test fun dismissAuthTest() {
+ sessionRule.delegateUntilTestEnd(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : 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",
+ mainSession.waitForJS("confirm('Confirm?')") as Boolean,
+ equalTo(true),
+ )
+
+ sessionRule.delegateDuringNextWait(object : 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",
+ mainSession.waitForJS("confirm('Confirm?')") as Boolean,
+ equalTo(false),
+ )
+ }
+
+ @Test
+ fun onFormResubmissionPrompt() {
+ mainSession.loadTestPath(RESUBMIT_CONFIRM)
+ sessionRule.waitForPageStop()
+
+ mainSession.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 : 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 : 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
+ mainSession.reload()
+
+ sessionRule.waitUntilCalled(object : 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
+ mainSession.reload()
+ sessionRule.waitUntilCalled(object : 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
+ @WithDisplay(width = 100, height = 100)
+ fun selectTestSimple() {
+ mainSession.loadTestPath(SELECT_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Should not be multiple", prompt.type, equalTo(PromptDelegate.ChoicePrompt.Type.SINGLE))
+ assertThat("There should be two choices", prompt.choices.size, equalTo(2))
+ assertThat("First choice is correct", prompt.choices[0].label, equalTo("ABC"))
+ assertThat("Second choice is correct", prompt.choices[1].label, equalTo("DEF"))
+ result.complete(prompt.confirm(prompt.choices[1]))
+ return result
+ }
+ })
+
+ val promise = mainSession.evaluatePromiseJS(
+ """new Promise(function(resolve) {
+ let events = [];
+ // Record the events for testing purposes.
+ for (const t of ["change", "input"]) {
+ document.querySelector("select").addEventListener(t, function(e) {
+ events.push(e.type + "(composed=" + e.composed + ")");
+ if (events.length == 2) {
+ resolve(events.join(" "));
+ }
+ });
+ }
+ })""",
+ )
+
+ mainSession.synthesizeTap(10, 10)
+ sessionRule.waitForResult(result)
+ assertThat(
+ "Events should be as expected",
+ promise.value as String,
+ equalTo("input(composed=true) change(composed=false)"),
+ )
+ }
+
+ @Test
+ @WithDisplay(width = 100, height = 100)
+ fun selectTestSize() {
+ mainSession.loadTestPath(SELECT_LISTBOX_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<Void>()
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Should not be multiple", prompt.type, equalTo(PromptDelegate.ChoicePrompt.Type.SINGLE))
+ assertThat("There should be three choices", prompt.choices.size, equalTo(3))
+ assertThat("First choice is correct", prompt.choices[0].label, equalTo("ABC"))
+ assertThat("Second choice is correct", prompt.choices[1].label, equalTo("DEF"))
+ assertThat("Third choice is correct", prompt.choices[2].label, equalTo("GHI"))
+ result.complete(null)
+ return null
+ }
+ })
+
+ mainSession.synthesizeTap(10, 10)
+ sessionRule.waitForResult(result)
+ }
+
+ @Test
+ @WithDisplay(width = 100, height = 100)
+ fun selectTestMultiple() {
+ mainSession.loadTestPath(SELECT_MULTIPLE_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<Void>()
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("Should be multiple", prompt.type, equalTo(PromptDelegate.ChoicePrompt.Type.MULTIPLE))
+ assertThat("There should be three choices", prompt.choices.size, equalTo(3))
+ assertThat("First choice is correct", prompt.choices[0].label, equalTo("ABC"))
+ assertThat("Second choice is correct", prompt.choices[1].label, equalTo("DEF"))
+ assertThat("Third choice is correct", prompt.choices[2].label, equalTo("GHI"))
+ result.complete(null)
+ return null
+ }
+ })
+
+ mainSession.synthesizeTap(10, 10)
+ sessionRule.waitForResult(result)
+ }
+
+ @Test
+ @WithDisplay(width = 100, height = 100)
+ fun selectTestUpdate() {
+ mainSession.loadTestPath(SELECT_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptUpdate(prompt: PromptDelegate.BasePrompt) {
+ val newPrompt: PromptDelegate.ChoicePrompt = prompt as PromptDelegate.ChoicePrompt
+ assertThat("First choice is correct", newPrompt.choices[0].label, equalTo("foo"))
+ assertThat("Second choice is correct", newPrompt.choices[1].label, equalTo("bar"))
+ assertThat("Third choice is correct", newPrompt.choices[2].label, equalTo("baz"))
+ result.complete(prompt.confirm(newPrompt.choices[2]))
+ }
+ }
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("There should be two choices", prompt.choices.size, equalTo(2))
+ prompt.setDelegate(promptInstanceDelegate)
+ return result
+ }
+ })
+
+ mainSession.evaluateJS(
+ """
+ document.querySelector("select").addEventListener("focus", () => {
+ window.setTimeout(() => {
+ document.querySelector("select").innerHTML =
+ "<option>foo</option><option>bar</option><option>baz</option>";
+ }, 100);
+ }, { once: true })
+ """.trimIndent(),
+ )
+
+ val promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ document.querySelector("select").addEventListener("change", e => {
+ resolve(e.target.value);
+ });
+ })
+ """.trimIndent(),
+ )
+
+ mainSession.synthesizeTap(10, 10)
+ sessionRule.waitForResult(result)
+ assertThat(
+ "Selected item should be as expected",
+ promise.value as String,
+ equalTo("baz"),
+ )
+ }
+
+ @Test
+ @WithDisplay(width = 100, height = 100)
+ fun selectTestDismiss() {
+ mainSession.loadTestPath(SELECT_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ assertThat("There should be two choices", prompt.choices.size, equalTo(2))
+ prompt.setDelegate(promptInstanceDelegate)
+ mainSession.evaluateJS("document.querySelector('select').blur()")
+ return result
+ }
+ })
+
+ mainSession.synthesizeTap(10, 10)
+ sessionRule.waitForResult(result)
+ }
+
+ @Test
+ fun onBeforeUnloadTest() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "dom.require_user_interaction_for_beforeunload" to false,
+ ),
+ )
+ mainSession.loadTestPath(BEFORE_UNLOAD)
+ sessionRule.waitForPageStop()
+
+ val result = GeckoResult<Void>()
+ sessionRule.delegateUntilTestEnd(object : 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 : 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
+ mainSession.evaluateJS("document.querySelector('#navigateAway').click()")
+ sessionRule.waitUntilCalled(object : 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)
+
+ // Although onBeforeUnloadPrompt is done, nsDocumentViewer might not clear
+ // mInPermitUnloadPrompt flag at this time yet. We need a wait to finish
+ // "nsDocumentViewer::PermitUnload" loop.
+ mainSession.waitForJS("new Promise(resolve => window.setTimeout(resolve, 100))")
+
+ // 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.
+ mainSession.evaluateJS("document.querySelector('#navigateAway2').click()")
+ sessionRule.waitUntilCalled(object : 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() {
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateUntilTestEnd(object : 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",
+ mainSession.waitForJS("prompt('Prompt:', 'default')") as String,
+ equalTo("foo"),
+ )
+ }
+
+ @Test fun colorTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onColorPrompt(session: GeckoSession, prompt: PromptDelegate.ColorPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Value should match", "#ffffff", equalTo(prompt.defaultValue))
+ assertThat("Predefined values size", 0, equalTo(prompt.predefinedValues!!.size))
+ return GeckoResult.fromValue(prompt.confirm("#123456"))
+ }
+ })
+
+ mainSession.evaluateJS(
+ """
+ this.c = document.getElementById('colorexample');
+ """.trimIndent(),
+ )
+
+ val promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise((resolve, reject) => {
+ this.c.addEventListener(
+ 'change',
+ event => resolve(event.target.value),
+ false
+ );
+ })
+ """.trimIndent(),
+ )
+
+ mainSession.evaluateJS("this.c.click();")
+
+ assertThat(
+ "Value should match",
+ promise.value as String,
+ equalTo("#123456"),
+ )
+ }
+
+ @Test fun colorTestWithDatalist() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onColorPrompt(session: GeckoSession, prompt: PromptDelegate.ColorPrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Value should match", "#ffffff", equalTo(prompt.defaultValue))
+ assertThat("Predefined values size", 2, equalTo(prompt.predefinedValues!!.size))
+ assertThat("First predefined value", "#000000", equalTo(prompt.predefinedValues?.get(0)))
+ assertThat("Second predefined value", "#808080", equalTo(prompt.predefinedValues?.get(1)))
+ return GeckoResult.fromValue(prompt.confirm("#123456"))
+ }
+ })
+
+ mainSession.evaluateJS(
+ """
+ this.c = document.getElementById('colorexample');
+ this.c.setAttribute('list', 'colorlist');
+ """.trimIndent(),
+ )
+
+ val promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise((resolve, reject) => {
+ this.c.addEventListener(
+ 'change',
+ event => resolve(event.target.value),
+ );
+ })
+ """.trimIndent(),
+ )
+ mainSession.evaluateJS("this.c.click();")
+
+ assertThat(
+ "Value should match",
+ promise.value as String,
+ equalTo("#123456"),
+ )
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun dateTest() {
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS(
+ """
+ document.body.addEventListener("click", () => {
+ document.getElementById('dateexample').showPicker();
+ });
+ """.trimIndent(),
+ )
+
+ mainSession.synthesizeTap(1, 1) // Provides user activation.
+ sessionRule.waitUntilCalled(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun dateTestByTap() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // By removing first element in PROMPT_HTML_PATH, dateexample becomes first element.
+ //
+ // TODO: What better calculation of element bounds for synthesizeTap?
+ mainSession.evaluateJS(
+ """
+ document.getElementById('selectexample').remove();
+ document.getElementById('dateexample').getBoundingClientRect();
+ """.trimIndent(),
+ )
+ mainSession.synthesizeTap(10, 10)
+
+ sessionRule.waitUntilCalled(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("<input type=date> is tapped", PromptDelegate.DateTimePrompt.Type.DATE, equalTo(prompt.type))
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun monthTestByTap() {
+ // Gecko doesn't have the widget for <input type=month>. But GeckoView can show the picker.
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ // TODO: What better calculation of element bounds for synthesizeTap?
+ mainSession.evaluateJS(
+ """
+ document.getElementById('selectexample').remove();
+ document.getElementById('dateexample').remove();
+ document.getElementById('weekexample').getBoundingClientRect();
+ """.trimIndent(),
+ )
+ mainSession.synthesizeTap(10, 10)
+
+ sessionRule.waitUntilCalled(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("<input type=month> is tapped", PromptDelegate.DateTimePrompt.Type.MONTH, equalTo(prompt.type))
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun dateTestParameters() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS(
+ """
+ document.getElementById('selectexample').remove();
+ document.getElementById('dateexample').min = "2022-01-01";
+ document.getElementById('dateexample').max = "2022-12-31";
+ document.getElementById('dateexample').step = "10";
+ document.getElementById('dateexample').getBoundingClientRect();
+ """.trimIndent(),
+ )
+ mainSession.synthesizeTap(10, 10)
+
+ sessionRule.waitUntilCalled(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("<input type=date> is tapped", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.DATE))
+ assertThat("min value is exported", prompt.minValue, equalTo("2022-01-01"))
+ assertThat("max value is exported", prompt.maxValue, equalTo("2022-12-31"))
+ assertThat("step value is exported", prompt.stepValue, equalTo("10"))
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun dateTestDismiss() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("<input type=date> is tapped", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.DATE))
+ prompt.setDelegate(promptInstanceDelegate)
+ mainSession.evaluateJS("document.getElementById('dateexample').blur()")
+ return result
+ }
+ })
+
+ mainSession.evaluateJS("document.getElementById('selectexample').remove()")
+ mainSession.synthesizeTap(10, 10)
+ sessionRule.waitForResult(result)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun monthTestDismiss() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("<input type=month> is tapped", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.MONTH))
+ prompt.setDelegate(promptInstanceDelegate)
+ mainSession.evaluateJS("document.getElementById('monthexample').blur()")
+ return result
+ }
+ })
+
+ mainSession.evaluateJS(
+ """
+ document.getElementById('selectexample').remove();
+ document.getElementById('dateexample').remove();
+ """.trimIndent(),
+ )
+ mainSession.synthesizeTap(10, 10)
+ sessionRule.waitForResult(result)
+ }
+
+ @Test fun fileTest() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false))
+
+ mainSession.loadTestPath(PROMPT_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ mainSession.evaluateJS("document.getElementById('fileexample').click();")
+
+ sessionRule.waitUntilCalled(object : 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 : 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 : 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 : 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 : 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 : 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 : 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 : 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 : 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 : 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..038515084f
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt
@@ -0,0 +1,253 @@
+/* -*- 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.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assume.assumeThat
+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.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.WebRequestError
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+
+@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
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+
+ val fontSizeJs = "parseFloat(window.getComputedStyle(document.querySelector('p')).fontSize)"
+ val initialFontSize = mainSession.evaluateJS(fontSizeJs) as Double
+
+ val textSizeFactor = 2.0f
+ settings.fontSizeFactor = textSizeFactor
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+ var fontSize = mainSession.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
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+ fontSize = mainSession.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 : 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 : 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..c6ffaf83fb
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt
@@ -0,0 +1,433 @@
+/* -*- 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.* // ktlint-disable no-wildcard-imports
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.view.Surface
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assert
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo
+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.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import java.lang.IllegalStateException
+import kotlin.math.absoluteValue
+import kotlin.math.max
+
+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() {
+ 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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(SurfaceInfo.Builder(surface).size(SCREEN_WIDTH, SCREEN_HEIGHT).build())
+ 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(SurfaceInfo.Builder(surface).size(SCREEN_WIDTH, SCREEN_HEIGHT).build())
+
+ 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(SurfaceInfo.Builder(newSurface).size(SCREEN_WIDTH, SCREEN_HEIGHT).build())
+ }
+
+ 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ mainSession.setActive(false)
+
+ // Deactivating the session should trigger a flush state change
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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)
+
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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
+ mainSession.loadTestPath(COLORS_HTML_PATH)
+ sessionRule.waitUntilCalled(object : 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() {
+ mainSession.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..63222e9732
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt
@@ -0,0 +1,913 @@
+/* -*- 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.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.graphics.Point
+import android.graphics.RectF
+import android.os.Build
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.json.JSONArray
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+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.AllowOrDeny
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.PromptDelegate
+import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate
+import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+@MediumTest
+@RunWith(Parameterized::class)
+@WithDisplay(width = 400, height = 400)
+class SelectionActionDelegateTest : BaseSessionTest() {
+ val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java)
+
+ @get:Rule
+ override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule)
+
+ enum class ContentType {
+ DIV, EDITABLE_ELEMENT, IFRAME, IFRAME_XORIGIN
+ }
+
+ 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),
+ arrayOf("#iframe-xorigin", ContentType.IFRAME_XORIGIN, "elit", false),
+ arrayOf("#x-input", ContentType.EDITABLE_ELEMENT, "adipisci", 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)
+ ContentType.IFRAME_XORIGIN -> SelectedFrameXOrigin(id, initialContent)
+ }
+ }
+
+ private val collapsedContent by lazy {
+ when (type) {
+ ContentType.DIV -> CollapsedDiv(id)
+ ContentType.EDITABLE_ELEMENT -> CollapsedEditableElement(id)
+ ContentType.IFRAME -> CollapsedFrame(id)
+ ContentType.IFRAME_XORIGIN -> CollapsedFrameXOrigin(id)
+ }
+ }
+
+ @Before
+ fun setup() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Writing clipboard requires foreground on Android 10.
+ activityRule.scenario.onActivity { activity ->
+ activity.onWindowFocusChanged(true)
+ }
+ }
+ }
+
+ /** 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,
+ ),
+ ),
+ )
+ }
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun request_html() {
+ if (editable) {
+ withHtmlClipboard("text", "<bold>text</bold>") {
+ if (type != ContentType.EDITABLE_ELEMENT) {
+ 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,
+ ACTION_PASTE_AS_PLAIN_TEXT,
+ ),
+ ),
+ )
+ } else {
+ 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"))
+ }
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun pasteAsPlainText() = assumingEditable(true) {
+ assumeThat("Paste as plain text works on content editable", type, not(equalTo(ContentType.EDITABLE_ELEMENT)))
+
+ withHtmlClipboard("pasted", "<bold>pasted</bold>") {
+ testThat(selectedContent, withResponse(ACTION_PASTE_AS_PLAIN_TEXT), 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 : 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";
+ document.querySelector('$id').offsetHeight; // flush layout
+ })()"""
+ val jsBorder10pxPadding10px = """(function() {
+ document.querySelector('$id').style.display = "block";
+ document.querySelector('$id').style.border = "10px solid";
+ document.querySelector('$id').style.padding = "10px";
+ document.querySelector('$id').offsetHeight; // flush layout
+ })()"""
+ val expectedDiff = RectF(10f, 10f, 10f, 10f) // left, top, right, bottom
+ testClientRect(selectedContent, jsCssReset, jsBorder10pxPadding10px, expectedDiff)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun clipboardReadAllow() {
+ assumeThat("Unnecessary to run multiple times", id, equalTo("#text"))
+
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true))
+
+ val url = createTestUrl(CLIPBOARD_READ_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ // Select allow
+ val result = GeckoResult<Void>()
+ mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowClipboardPermissionRequest(
+ session: GeckoSession,
+ perm: ClipboardPermission,
+ ):
+ GeckoResult<AllowOrDeny> {
+ assertThat("URI should match", perm.uri, startsWith(url))
+ assertThat(
+ "Type should match",
+ perm.type,
+ equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ),
+ )
+ assertThat("screenPoint should match", perm.screenPoint, equalTo(Point(50, 50)))
+ return GeckoResult.allow()
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onAlertPrompt(
+ session: GeckoSession,
+ prompt: PromptDelegate.AlertPrompt,
+ ):
+ GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Message should match", "allow", equalTo(prompt.message))
+ result.complete(null)
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ mainSession.synthesizeTap(50, 50) // Provides user activation.
+ sessionRule.waitForResult(result)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun clipboardReadDeny() {
+ assumeThat("Unnecessary to run multiple times", id, equalTo("#text"))
+
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true))
+
+ val url = createTestUrl(CLIPBOARD_READ_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ // Select deny
+ val result = GeckoResult<Void>()
+ mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onShowClipboardPermissionRequest(
+ session: GeckoSession,
+ perm: ClipboardPermission,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ assertThat("URI should match", perm.uri, startsWith(url))
+ assertThat(
+ "Type should match",
+ perm.type,
+ equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ),
+ )
+ return GeckoResult.deny()
+ }
+
+ @AssertCalled(count = 1, order = [2])
+ override fun onAlertPrompt(
+ session: GeckoSession,
+ prompt: PromptDelegate.AlertPrompt,
+ ):
+ GeckoResult<PromptDelegate.PromptResponse> {
+ assertThat("Message should match", "deny", equalTo(prompt.message))
+ result.complete(null)
+ return GeckoResult.fromValue(prompt.dismiss())
+ }
+ })
+
+ mainSession.synthesizeTap(50, 50) // Provides user activation.
+ sessionRule.waitForResult(result)
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun clipboardReadDeactivate() {
+ assumeThat("Unnecessary to run multiple times", id, equalTo("#text"))
+
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true))
+
+ val url = createTestUrl(CLIPBOARD_READ_HTML_PATH)
+ mainSession.loadUri(url)
+ mainSession.waitForPageStop()
+
+ val result = GeckoResult<Void>()
+ mainSession.delegateDuringNextWait(object : SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowClipboardPermissionRequest(
+ session: GeckoSession,
+ perm: ClipboardPermission,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ assertThat(
+ "Type should match",
+ perm.type,
+ equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ),
+ )
+ result.complete(null)
+ return GeckoResult()
+ }
+ })
+
+ mainSession.synthesizeTap(50, 50) // Provides user activation.
+ sessionRule.waitForResult(result)
+
+ mainSession.delegateDuringNextWait(object : SelectionActionDelegate {
+ @AssertCalled
+ override fun onDismissClipboardPermissionRequest(session: GeckoSession) {
+ }
+ })
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ sessionRule.waitForPageStop()
+ }
+
+ /** 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 : SelectionActionDelegate {
+ override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection) {
+ respondingWith(selection)
+ }
+ })
+
+ content.select()
+ mainSession.waitUntilCalled(object : 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 screenRect = RectF()
+ content.select()
+ mainSession.waitUntilCalled(object : SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowActionRequest(session: GeckoSession, selection: Selection) {
+ screenRect = selection.screenRect!!
+ }
+ })
+
+ screenRect
+ }
+
+ val screenRectA = requestClientRect(initialJsA)
+ val screenRectB = requestClientRect(initialJsB)
+
+ val fuzzyEqual = { a: Float, b: Float, e: Float -> Math.abs(a + e - b) <= 1 }
+ val result = fuzzyEqual(screenRectA.top, screenRectB.top, expectedDiff.top) &&
+ fuzzyEqual(screenRectA.left, screenRectB.left, expectedDiff.left) &&
+ fuzzyEqual(screenRectA.width(), screenRectB.width(), expectedDiff.width()) &&
+ fuzzyEqual(screenRectA.height(), screenRectB.height(), expectedDiff.height())
+
+ assertThat(
+ "Selection rect is not at expected location. a$screenRectA b$screenRectB",
+ 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 {
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P && content.isEmpty()) {
+ clipboard.clearPrimaryClip()
+ } else {
+ 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 withHtmlClipboard(plainText: String = "", html: String = "", lambda: () -> Unit) {
+ val oldClip = clipboard.primaryClip
+ try {
+ clipboard.setPrimaryClip(ClipData.newHtmlText("", plainText, html))
+
+ 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)
+ }
+
+ open inner class SelectedFrameXOrigin(
+ val id: String,
+ override val initialContent: String,
+ ) : SelectedContent {
+ override fun focus() {
+ mainSession.evaluateJS("document.querySelector('$id').contentWindow.postMessage({ type: 'focus' }, '*')")
+ }
+
+ protected fun selectTo(to: Int) {
+ mainSession.evaluateJS("document.querySelector('$id').contentWindow.postMessage({ type: 'select', length: $to }, '*')")
+ }
+
+ override fun select() = selectTo(initialContent.length)
+
+ override val content: String get() {
+ val promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ window.addEventListener('message', e => {
+ resolve(e.data);
+ }, { once: true });
+ document.querySelector('$id').contentDocument.postMessage({ type: 'content' }, '*');
+ });
+ """,
+ )
+ return promise.value as String
+ }
+
+ override val selectionOffsets: Pair<Int, Int> get() {
+ val promise = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ window.addEventListener('message', e => {
+ resolve(e.data);
+ }, { once: true });
+ document.querySelector('$id').contentDocument.postMessage({ type: 'selectedOffset' }, '*');
+ });
+ """,
+ )
+ val offsets = promise.value as JSONArray
+ return Pair(offsets[0] as Int, offsets[1] as Int)
+ }
+ }
+
+ inner class CollapsedFrameXOrigin(id: String) : SelectedFrameXOrigin(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 : 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.screenRect!!.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 : 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 : 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..50f64301fd
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt
@@ -0,0 +1,240 @@
+/* -*- 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.Bundle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+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 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()
+
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @Test fun open_repeated() {
+ for (i in 1..5) {
+ mainSession.close()
+ mainSession.open()
+ }
+ mainSession.reload()
+ mainSession.waitForPageStop()
+ }
+
+ @Test fun open_allowCallsWhileClosed() {
+ mainSession.close()
+
+ mainSession.loadTestPath(HELLO_HTML_PATH)
+ mainSession.reload()
+
+ mainSession.open()
+ mainSession.waitForPageStops(2)
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun open_throwOnAlreadyOpen() {
+ // Throw exception if retrying to open again; otherwise we would leak the old open window.
+ mainSession.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())
+ }
+
+ // Waits for 4 requestAnimationFrame calls and computes rate
+ private fun computeRequestAnimationFrameRate(session: GeckoSession): Double {
+ return session.evaluateJS(
+ """
+ new Promise(resolve => {
+ let start = 0;
+ let frames = 0;
+ const ITERATIONS = 4;
+ function raf() {
+ if (frames === 0) {
+ start = window.performance.now();
+ }
+ if (frames === ITERATIONS) {
+ resolve((window.performance.now() - start) / ITERATIONS);
+ }
+ frames++;
+ window.requestAnimationFrame(raf);
+ }
+ window.requestAnimationFrame(raf);
+ });
+ """,
+ ) as Double
+ }
+
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun asyncScriptsSuspendedWhileInactive() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "privacy.reduceTimerPrecision" to false,
+ // This makes the throttled frame rate 4 times faster than normal,
+ // so this test doesn't time out. Should still be significantly slower tha
+ // the active frame rate so we can measure the effects
+ "layout.throttled_frame_rate" to 4,
+ ),
+ )
+
+ 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)
+ assertThat(
+ "docShell shouldn't be active after calling setActive(false)",
+ mainSession.active,
+ equalTo(false),
+ )
+
+ mainSession.evaluateJS(
+ """
+ function fail() {
+ document.documentElement.style.backgroundColor = 'green';
+ }
+ setTimeout(fail, 1);
+ fetch("missing.html").catch(fail);
+ """,
+ )
+
+ var rafRate = computeRequestAnimationFrameRate(mainSession)
+ assertThat(
+ "requestAnimationFrame should be called about once a second",
+ rafRate,
+ greaterThan(450.0),
+ )
+ assertThat(
+ "requestAnimationFrame should be called about once a second",
+ rafRate,
+ lessThan(10000.0),
+ )
+
+ val isNotGreen = mainSession.evaluateJS(
+ "document.documentElement.style.backgroundColor !== 'green'",
+ ) as Boolean
+ assertThat("timeouts have not run yet", isNotGreen, equalTo(true))
+
+ // 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),
+ )
+
+ // At 60fps, once a frame is about 16.6 ms
+ rafRate = computeRequestAnimationFrameRate(mainSession)
+ assertThat(
+ "requestAnimationFrame should be called about once a frame",
+ rafRate,
+ lessThan(60.0),
+ )
+ assertThat(
+ "requestAnimationFrame should be called about once a frame",
+ rafRate,
+ greaterThan(5.0),
+ )
+ }
+
+ 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..592aa442f8
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt
@@ -0,0 +1,874 @@
+/* -*- 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.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_DISABLED
+import org.mozilla.geckoview.ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT
+import org.mozilla.geckoview.ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT
+import org.mozilla.geckoview.GeckoSessionSettings
+import org.mozilla.geckoview.StorageController
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class StorageControllerTest : BaseSessionTest() {
+
+ private val storageController
+ get() = sessionRule.runtime.storageController
+
+ @Test fun clearData() {
+ mainSession.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ mainSession.evaluateJS(
+ """
+ localStorage.setItem('ctx', 'test');
+ document.cookie = 'ctx=test';
+ """,
+ )
+
+ var localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ var cookie = mainSession.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 = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ cookie = mainSession.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() {
+ mainSession.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ mainSession.evaluateJS(
+ """
+ localStorage.setItem('ctx', 'test');
+ document.cookie = 'ctx=test';
+ """,
+ )
+
+ var localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ var cookie = mainSession.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 = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ cookie = mainSession.evaluateJS(
+ """
+ document.cookie || 'null'
+ """,
+ ) as String
+
+ // With LSNG disabled, storage is also cleared when cookies are,
+ // see bug 1592752.
+ if (sessionRule.getPrefs("dom.storage.enable_unsupported_legacy_implementation")[0] as Boolean == false) {
+ 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"),
+ )
+
+ mainSession.evaluateJS(
+ """
+ document.cookie = 'ctx=test';
+ """,
+ )
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearData(
+ StorageController.ClearFlags.DOM_STORAGES,
+ ),
+ )
+
+ localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ cookie = mainSession.evaluateJS(
+ """
+ document.cookie || 'null'
+ """,
+ ) as String
+
+ assertThat(
+ "Local storage value should match",
+ localStorage,
+ equalTo("null"),
+ )
+ assertThat(
+ "Cookie value should match",
+ cookie,
+ equalTo("ctx=test"),
+ )
+
+ mainSession.evaluateJS(
+ """
+ localStorage.setItem('ctx', 'test');
+ """,
+ )
+
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearData(
+ StorageController.ClearFlags.SITE_DATA,
+ ),
+ )
+
+ localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ cookie = mainSession.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() {
+ mainSession.loadUri("https://example.com")
+ sessionRule.waitForPageStop()
+
+ mainSession.evaluateJS(
+ """
+ localStorage.setItem('ctx', 'test');
+ document.cookie = 'ctx=test';
+ """,
+ )
+
+ var localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ var cookie = mainSession.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 = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ cookie = mainSession.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 = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ cookie = mainSession.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 clearDataFromBaseDomain() {
+ var domains = arrayOf("example.com", "test1.example.com")
+
+ // Set site data for both root domain and subdomain.
+ for (domain in domains) {
+ mainSession.loadUri("https://" + domain)
+ sessionRule.waitForPageStop()
+
+ mainSession.evaluateJS(
+ """
+ localStorage.setItem('ctx', 'test');
+ document.cookie = 'ctx=test';
+ """,
+ )
+
+ var localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ var cookie = mainSession.evaluateJS(
+ """
+ document.cookie || 'null'
+ """,
+ ) as String
+
+ assertThat(
+ "Local storage value should match",
+ localStorage,
+ equalTo("test"),
+ )
+ assertThat(
+ "Cookie value should match",
+ cookie,
+ equalTo("ctx=test"),
+ )
+ }
+
+ // Clear data for an unrelated domain. The test data should still be
+ // set.
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearDataFromBaseDomain(
+ "test.com",
+ StorageController.ClearFlags.ALL,
+ ),
+ )
+
+ for (domain in domains) {
+ mainSession.loadUri("https://" + domain)
+ sessionRule.waitForPageStop()
+
+ var localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ var cookie = mainSession.evaluateJS(
+ """
+ document.cookie || 'null'
+ """,
+ ) as String
+
+ assertThat(
+ "Local storage value should match",
+ localStorage,
+ equalTo("test"),
+ )
+ assertThat(
+ "Cookie value should match",
+ cookie,
+ equalTo("ctx=test"),
+ )
+ }
+
+ // Finally, clear the test data by base domain. This should clear both,
+ // the root domain and the subdomain.
+ sessionRule.waitForResult(
+ sessionRule.runtime.storageController.clearDataFromBaseDomain(
+ "example.com",
+ StorageController.ClearFlags.ALL,
+ ),
+ )
+
+ for (domain in domains) {
+ mainSession.loadUri("https://" + domain)
+ sessionRule.waitForPageStop()
+
+ var localStorage = mainSession.evaluateJS(
+ """
+ localStorage.getItem('ctx') || 'null'
+ """,
+ ) as String
+
+ var cookie = mainSession.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"),
+ )
+ }
+
+ @Test fun setCookieBannerModeForDomain() {
+ val contentBlocking = sessionRule.runtime.settings.contentBlocking
+ contentBlocking.cookieBannerMode = COOKIE_BANNER_MODE_REJECT
+
+ val session = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("1")
+ .build(),
+ )
+ session.loadUri("https://example.com")
+ session.waitForPageStop()
+
+ var mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ false,
+ ),
+ )
+
+ assertThat(
+ "Cookie banner mode should match",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_REJECT),
+ )
+
+ sessionRule.waitForResult(
+ storageController.setCookieBannerModeForDomain(
+ "https://example.com",
+ COOKIE_BANNER_MODE_REJECT_OR_ACCEPT,
+ false,
+ ),
+ )
+
+ mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ false,
+ ),
+ )
+
+ assertThat(
+ "Cookie banner mode should match",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT),
+ )
+ }
+
+ @Test
+ fun setCookieBannerModeAndPersistInPrivateBrowsingForDomain() {
+ val contentBlocking = sessionRule.runtime.settings.contentBlocking
+ contentBlocking.cookieBannerMode = COOKIE_BANNER_MODE_REJECT
+
+ val session = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("1")
+ .usePrivateMode(true)
+ .build(),
+ )
+ session.loadUri("https://example.com")
+ session.waitForPageStop()
+
+ var mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ true,
+ ),
+ )
+
+ assertThat(
+ "Cookie banner mode should match",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_REJECT),
+ )
+
+ sessionRule.waitForResult(
+ storageController.setCookieBannerModeAndPersistInPrivateBrowsingForDomain(
+ "https://example.com",
+ COOKIE_BANNER_MODE_REJECT_OR_ACCEPT,
+ ),
+ )
+
+ mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ true,
+ ),
+ )
+
+ assertThat(
+ "Cookie banner mode should match",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT),
+ )
+
+ session.close()
+
+ mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ true,
+ ),
+ )
+
+ assertThat(
+ "Cookie banner mode should match",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT),
+ )
+ }
+
+ @Test
+ fun getCookieBannerModeForDomain() {
+ val contentBlocking = sessionRule.runtime.settings.contentBlocking
+ contentBlocking.cookieBannerMode = COOKIE_BANNER_MODE_DISABLED
+
+ val session = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("1")
+ .build(),
+ )
+ session.loadUri("https://example.com")
+ session.waitForPageStop()
+
+ try {
+ val mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ false,
+ ),
+ )
+ assertThat(
+ "Cookie banner mode should match",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_DISABLED),
+ )
+ } catch (e: Exception) {
+ assertThat(
+ "Cookie banner mode should match",
+ e.message,
+ containsString("The cookie banner handling service is not available"),
+ )
+ }
+ }
+
+ @Test fun removeCookieBannerModeForDomain() {
+ val contentBlocking = sessionRule.runtime.settings.contentBlocking
+ contentBlocking.cookieBannerModePrivateBrowsing = COOKIE_BANNER_MODE_REJECT
+ sessionRule.setPrefsUntilTestEnd(mapOf("cookiebanners.service.mode.privateBrowsing" to COOKIE_BANNER_MODE_REJECT))
+
+ val session = sessionRule.createOpenSession(
+ GeckoSessionSettings.Builder(mainSession.settings)
+ .contextId("1")
+ .build(),
+ )
+ session.loadUri("https://example.com")
+ session.waitForPageStop()
+
+ sessionRule.waitForResult(
+ storageController.setCookieBannerModeForDomain(
+ "https://example.com",
+ COOKIE_BANNER_MODE_REJECT_OR_ACCEPT,
+ true,
+ ),
+ )
+
+ var mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ true,
+ ),
+ )
+
+ assertThat(
+ "Cookie banner mode should match $COOKIE_BANNER_MODE_REJECT_OR_ACCEPT but it is $mode",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT),
+ )
+
+ sessionRule.waitForResult(
+ storageController.removeCookieBannerModeForDomain(
+ "https://example.com",
+ true,
+ ),
+ )
+
+ mode = sessionRule.waitForResult(
+ storageController.getCookieBannerModeForDomain(
+ "https://example.com",
+ true,
+ ),
+ )
+
+ assertThat(
+ "Cookie banner mode should match $COOKIE_BANNER_MODE_REJECT but it is $mode",
+ mode,
+ equalTo(COOKIE_BANNER_MODE_REJECT),
+ )
+ }
+}
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..42286c47a7
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt
@@ -0,0 +1,131 @@
+/* -*- 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.CoreMatchers.equalTo
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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/TemporaryProfileRule.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java
new file mode 100644
index 0000000000..ee503af732
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java
@@ -0,0 +1,35 @@
+package org.mozilla.geckoview.test;
+
+import java.io.File;
+import java.io.IOException;
+import org.junit.rules.ExternalResource;
+import org.junit.rules.TemporaryFolder;
+import org.mozilla.geckoview.test.rule.TestHarnessException;
+
+/** Lazily provides a temporary profile folder for tests. */
+public class TemporaryProfileRule extends ExternalResource {
+ TemporaryFolder mTemporaryFolder;
+ File mProfileFolder;
+
+ @Override
+ protected void after() {
+ if (mTemporaryFolder != null) {
+ mTemporaryFolder.delete();
+ mProfileFolder = null;
+ }
+ }
+
+ public File get() {
+ if (mProfileFolder == null) {
+ mTemporaryFolder = new TemporaryFolder();
+ try {
+ mTemporaryFolder.create();
+ mProfileFolder = mTemporaryFolder.newFolder("test-profile");
+ } catch (IOException ex) {
+ throw new TestHarnessException(ex);
+ }
+ }
+
+ return mProfileFolder;
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java
new file mode 100644
index 0000000000..787448a859
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelFileDescriptor.AutoCloseOutputStream;
+import android.util.Log;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
+/** TestContentProvider provides any data via content resolver by content:// */
+public class TestContentProvider extends ContentProvider {
+ private static final String LOGTAG = "TestContentProvider";
+ private static byte[] sTestData;
+ private static String sMimeType;
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public String getType(final Uri uri) {
+ return sMimeType;
+ }
+
+ @Override
+ public Cursor query(
+ final Uri uri,
+ final String[] projection,
+ final String selection,
+ final String[] selectionArgs,
+ final String sortOrder) {
+ return null;
+ }
+
+ @Override
+ public Uri insert(final Uri uri, final ContentValues values) {
+ return null;
+ }
+
+ @Override
+ public int delete(final Uri uri, final String selection, final String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public int update(
+ final Uri uri,
+ final ContentValues values,
+ final String selection,
+ final String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public ParcelFileDescriptor openFile(final Uri uri, final String mode)
+ throws FileNotFoundException {
+ if (sTestData == null) {
+ throw new FileNotFoundException("No test data for: " + uri);
+ }
+
+ ParcelFileDescriptor[] pipe = null;
+ AutoCloseOutputStream outputStream = null;
+
+ try {
+ try {
+ pipe = ParcelFileDescriptor.createPipe();
+ outputStream = new AutoCloseOutputStream(pipe[1]);
+ outputStream.write(sTestData);
+ outputStream.flush();
+ return pipe[0];
+ } finally {
+ if (outputStream != null) {
+ outputStream.close();
+ }
+ if (pipe != null && pipe[1] != null) {
+ pipe[1].close();
+ }
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "openFile throws an I/O exception: ", e);
+ }
+
+ throw new FileNotFoundException("Could not open uri for: " + uri);
+ }
+
+ /**
+ * Set test data that is used from content resolver.
+ *
+ * @param data test data
+ * @param mimeType A mime type of test data.
+ */
+ public static void setTestData(final byte[] data, final String mimeType) {
+ sTestData = data;
+ sMimeType = mimeType;
+ }
+}
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..32917ac25b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java
@@ -0,0 +1,281 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+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 java.io.File;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.test.util.UiThreadUtils;
+
+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(final boolean result, final String msg) {
+ mResult = result;
+ mMsg = msg;
+ }
+
+ public EvalResult(final 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(final 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(final ComponentName className, final IBinder service) {
+ mService = new Messenger(service);
+ }
+
+ @Override
+ public void onServiceDisconnected(final 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 expectedProcessType The type of process the incoming crash is expected to be for.
+ */
+ public void setEvalNextCrashDump(final String expectedProcessType) {
+ setEvalResult(null);
+ mReceiver.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ final Bundle bundle = new Bundle();
+ bundle.putString(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, expectedProcessType);
+ final Message msg = Message.obtain(null, MSG_EVAL_NEXT_CRASH_DUMP, bundle);
+ msg.replyTo = mMessenger;
+
+ try {
+ mService.send(msg);
+ } catch (final RemoteException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+ });
+ }
+
+ public boolean connect(final long timeoutMillis) {
+ final 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(final 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 String mExpectedProcessType;
+
+ MessageHandler() {}
+
+ @Override
+ public void handleMessage(final Message msg) {
+ if (msg.what == MSG_EVAL_NEXT_CRASH_DUMP) {
+ mReplyToMessenger = msg.replyTo;
+ Bundle bundle = (Bundle) msg.obj;
+ mExpectedProcessType = bundle.getString(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE);
+ return;
+ }
+
+ super.handleMessage(msg);
+ }
+
+ public void reportResult(final EvalResult result) {
+ if (mReplyToMessenger == null) {
+ return;
+ }
+
+ final Message msg = Message.obtain(null, MSG_CRASH_DUMP_EVAL_RESULT);
+ msg.setData(result.asBundle());
+
+ try {
+ mReplyToMessenger.send(msg);
+ } catch (final RemoteException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+
+ mReplyToMessenger = null;
+ }
+
+ public String getExpectedProcessType() {
+ return mExpectedProcessType;
+ }
+ }
+
+ 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 String expectedProcessType = mMsgHandler.getExpectedProcessType();
+ final String processType = intent.getStringExtra(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE);
+ if (processType == null) {
+ return new EvalResult(false, "Intent missing process type");
+ }
+ if (!processType.equals(expectedProcessType)) {
+ return new EvalResult(
+ false, "Expected process type " + expectedProcessType + ", found " + processType);
+ }
+
+ return new EvalResult(true, "Crash Dump OK");
+ }
+
+ @Override
+ public synchronized int onStartCommand(final Intent intent, final int flags, final int startId) {
+ if (mMsgHandler != null) {
+ mMsgHandler.reportResult(evalCrashInfo(intent));
+ // We must manually call stopSelf() here to ensure the Service gets killed once the client
+ // unbinds. If we don't, then when the next client attempts to bind for a different test,
+ // onBind() will not be called, and mMsgHandler will not get set.
+ stopSelf();
+ 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(final Intent intent) {
+ mMsgHandler = new MessageHandler();
+ mMessenger = new Messenger(mMsgHandler);
+ return mMessenger.getBinder();
+ }
+
+ @Override
+ public synchronized boolean onUnbind(final Intent intent) {
+ mMsgHandler = null;
+ mMessenger = null;
+ return false;
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java
new file mode 100644
index 0000000000..90db5b88f2
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java
@@ -0,0 +1,404 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+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.IBinder;
+import android.os.Message;
+import android.os.Messenger;
+import android.os.RemoteException;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoRuntimeSettings;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule;
+
+public class TestRuntimeService extends Service
+ implements GeckoSession.ProgressDelegate, GeckoRuntime.Delegate {
+ // Used by the client to register themselves
+ public static final int MESSAGE_REGISTER = 1;
+ // Sent when the first page load completes
+ public static final int MESSAGE_INIT_COMPLETE = 2;
+ // Sent when GeckoRuntime exits
+ public static final int MESSAGE_QUIT = 3;
+ // Reload current session
+ public static final int MESSAGE_RELOAD = 4;
+ // Load URI in current session
+ public static final int MESSAGE_LOAD_URI = 5;
+ // Receive a reply for a message
+ public static final int MESSAGE_REPLY = 6;
+ // Execute action on the remote service
+ public static final int MESSAGE_PAGE_STOP = 7;
+
+ // Used by clients to know the first safe ID that can be used
+ // for additional message types
+ public static final int FIRST_SAFE_MESSAGE = MESSAGE_PAGE_STOP + 1;
+
+ // Generic service instances
+ public static final class instance0 extends TestRuntimeService {}
+
+ public static final class instance1 extends TestRuntimeService {}
+
+ protected GeckoRuntime mRuntime;
+ protected GeckoSession mSession;
+ protected GeckoBundle mTestData;
+
+ private Messenger mClient;
+
+ private class TestHandler extends Handler {
+ @Override
+ public void handleMessage(@NonNull final Message msg) {
+ final Bundle msgData = msg.getData();
+ final GeckoBundle data =
+ msgData != null ? GeckoBundle.fromBundle(msgData.getBundle("data")) : null;
+ final String id = msgData != null ? msgData.getString("id") : null;
+
+ switch (msg.what) {
+ case MESSAGE_REGISTER:
+ mClient = msg.replyTo;
+ return;
+ case MESSAGE_QUIT:
+ // Unceremoniously exit
+ System.exit(0);
+ return;
+ case MESSAGE_RELOAD:
+ mSession.reload();
+ break;
+ case MESSAGE_LOAD_URI:
+ mSession.loadUri(data.getString("uri"));
+ break;
+ default:
+ {
+ final GeckoResult<GeckoBundle> result =
+ TestRuntimeService.this.handleMessage(msg.what, data);
+ if (result != null) {
+ result.accept(
+ bundle -> {
+ final GeckoBundle reply = new GeckoBundle();
+ reply.putString("id", id);
+ reply.putBundle("data", bundle);
+ TestRuntimeService.this.sendMessage(MESSAGE_REPLY, reply);
+ });
+ }
+ return;
+ }
+ }
+ }
+ }
+
+ final Messenger mMessenger = new Messenger(new TestHandler());
+
+ @Override
+ public void onShutdown() {
+ sendMessage(MESSAGE_QUIT);
+ }
+
+ protected void sendMessage(final int message) {
+ sendMessage(message, null);
+ }
+
+ protected void sendMessage(final int message, final GeckoBundle bundle) {
+ if (mClient == null) {
+ throw new IllegalStateException("Service is not connected yet!");
+ }
+
+ Message msg = Message.obtain(null, message);
+ msg.replyTo = mMessenger;
+ if (bundle != null) {
+ msg.setData(bundle.toBundle());
+ }
+
+ try {
+ mClient.send(msg);
+ } catch (RemoteException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ private boolean mFirstPageStop = true;
+
+ @Override
+ public void onPageStop(@NonNull final GeckoSession session, final boolean success) {
+ // Notify the subclass that the session is ready to use
+ if (success && mFirstPageStop) {
+ onSessionReady(session);
+ mFirstPageStop = false;
+ sendMessage(MESSAGE_INIT_COMPLETE);
+ } else {
+ sendMessage(MESSAGE_PAGE_STOP);
+ }
+ }
+
+ protected void onSessionReady(final GeckoSession session) {}
+
+ @Override
+ public void onDestroy() {
+ // Sometimes the service doesn't die on it's own so we need to kill it here.
+ System.exit(0);
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(final Intent intent) {
+ // Request to be killed as soon as the client unbinds.
+ stopSelf();
+
+ if (mRuntime != null) {
+ // We only expect one client
+ throw new RuntimeException("Multiple clients !?");
+ }
+
+ mRuntime = createRuntime(getApplicationContext(), intent);
+ mRuntime.setDelegate(this);
+
+ if (intent.hasExtra("test-data")) {
+ mTestData = GeckoBundle.fromBundle(intent.getBundleExtra("test-data"));
+ }
+
+ mSession = createSession(intent);
+ mSession.setProgressDelegate(this);
+ mSession.open(mRuntime);
+
+ return mMessenger.getBinder();
+ }
+
+ /** Override this to handle custom messages. */
+ protected GeckoResult<GeckoBundle> handleMessage(final int messageId, final GeckoBundle data) {
+ return null;
+ }
+
+ /** Override this to change the default runtime */
+ protected GeckoRuntime createRuntime(
+ final @NonNull Context context, final @NonNull Intent intent) {
+ return GeckoRuntime.create(
+ context, new GeckoRuntimeSettings.Builder().extras(intent.getExtras()).build());
+ }
+
+ /** Override this to change the default session */
+ protected GeckoSession createSession(final Intent intent) {
+ return new GeckoSession();
+ }
+
+ /**
+ * Starts GeckoRuntime in the process given in input, and waits for the MESSAGE_INIT_COMPLETE
+ * event that's fired when the first GeckoSession receives the onPageStop event.
+ *
+ * <p>We wait for a page load to make sure that everything started up correctly (as opposed to
+ * quitting during the startup procedure).
+ */
+ public static class RuntimeInstance<T> {
+ public boolean isConnected = false;
+ public GeckoResult<Void> disconnected = new GeckoResult<>();
+ public GeckoResult<Void> started = new GeckoResult<>();
+ public GeckoResult<Void> quitted = new GeckoResult<>();
+ public final Context context;
+ public final Class<T> service;
+
+ private final File mProfileFolder;
+ private final GeckoBundle mTestData;
+ private final ClientHandler mClientHandler = new ClientHandler();
+ private Messenger mMessenger;
+ private Messenger mServiceMessenger;
+ private GeckoResult<Void> mPageStop = null;
+
+ private Map<String, GeckoResult<GeckoBundle>> mPendingMessages = new HashMap<>();
+
+ protected RuntimeInstance(
+ final Context context, final Class<T> service, final File profileFolder) {
+ this(context, service, profileFolder, null);
+ }
+
+ protected RuntimeInstance(
+ final Context context,
+ final Class<T> service,
+ final File profileFolder,
+ final GeckoBundle testData) {
+ this.context = context;
+ this.service = service;
+ mProfileFolder = profileFolder;
+ mTestData = testData;
+ }
+
+ public static <T> RuntimeInstance<T> start(
+ final Context context, final Class<T> service, final File profileFolder) {
+ RuntimeInstance<T> instance = new RuntimeInstance<>(context, service, profileFolder);
+ instance.sendIntent();
+ return instance;
+ }
+
+ class ClientHandler extends Handler implements ServiceConnection {
+ @Override
+ public void handleMessage(@NonNull Message msg) {
+ switch (msg.what) {
+ case MESSAGE_INIT_COMPLETE:
+ started.complete(null);
+ break;
+ case MESSAGE_QUIT:
+ quitted.complete(null);
+ // No reason to keep the service around anymore
+ context.unbindService(mClientHandler);
+ break;
+ case MESSAGE_REPLY:
+ final String messageId = msg.getData().getString("id");
+ final Bundle data = msg.getData().getBundle("data");
+ mPendingMessages.remove(messageId).complete(GeckoBundle.fromBundle(data));
+ break;
+ case MESSAGE_PAGE_STOP:
+ if (mPageStop != null) {
+ mPageStop.complete(null);
+ mPageStop = null;
+ }
+ break;
+ default:
+ RuntimeInstance.this.handleMessage(msg);
+ break;
+ }
+ }
+
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ mMessenger = new Messenger(mClientHandler);
+ mServiceMessenger = new Messenger(binder);
+ isConnected = true;
+
+ RuntimeInstance.this.sendMessage(MESSAGE_REGISTER);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ isConnected = false;
+ context.unbindService(this);
+ disconnected.complete(null);
+ }
+ }
+
+ /** Override this to handle additional messages. */
+ protected void handleMessage(Message msg) {}
+
+ /** Override to modify the intent sent to the service */
+ protected Intent createIntent(final Context context) {
+ return new Intent(context, service);
+ }
+
+ private GeckoResult<GeckoBundle> sendMessageInternal(
+ final int message, final GeckoBundle bundle, final GeckoResult<GeckoBundle> result) {
+ if (!isConnected) {
+ throw new IllegalStateException("Service is not connected yet!");
+ }
+
+ final String messageId = UUID.randomUUID().toString();
+ GeckoBundle data = new GeckoBundle();
+ data.putString("id", messageId);
+ if (bundle != null) {
+ data.putBundle("data", bundle);
+ }
+
+ Message msg = Message.obtain(null, message);
+ msg.replyTo = mMessenger;
+ msg.setData(data.toBundle());
+
+ if (result != null) {
+ mPendingMessages.put(messageId, result);
+ }
+
+ try {
+ mServiceMessenger.send(msg);
+ } catch (RemoteException ex) {
+ throw new RuntimeException(ex);
+ }
+
+ return result;
+ }
+
+ private GeckoResult<Void> waitForPageStop() {
+ if (mPageStop == null) {
+ mPageStop = new GeckoResult<>();
+ }
+ return mPageStop;
+ }
+
+ protected GeckoResult<GeckoBundle> query(final int message) {
+ return query(message, null);
+ }
+
+ protected GeckoResult<GeckoBundle> query(final int message, final GeckoBundle bundle) {
+ final GeckoResult<GeckoBundle> result = new GeckoResult<>();
+ return sendMessageInternal(message, bundle, result);
+ }
+
+ protected void sendMessage(final int message) {
+ sendMessage(message, null);
+ }
+
+ protected void sendMessage(final int message, final GeckoBundle bundle) {
+ sendMessageInternal(message, bundle, null);
+ }
+
+ protected void sendIntent() {
+ final Intent intent = createIntent(context);
+ intent.putExtra("args", "-profile " + mProfileFolder.getAbsolutePath());
+ if (mTestData != null) {
+ intent.putExtra("test-data", mTestData.toBundle());
+ }
+ context.bindService(intent, mClientHandler, Context.BIND_AUTO_CREATE);
+ }
+
+ /**
+ * Quits the current runtime.
+ *
+ * @return a {@link GeckoResult} that is resolved when the service fully disconnects.
+ */
+ public GeckoResult<Void> quit() {
+ sendMessage(MESSAGE_QUIT);
+ return disconnected;
+ }
+
+ /**
+ * Reloads the current session.
+ *
+ * @return A {@link GeckoResult} that is resolved when the page is fully reloaded.
+ */
+ public GeckoResult<Void> reload() {
+ sendMessage(MESSAGE_RELOAD);
+ return waitForPageStop();
+ }
+
+ /**
+ * Load a test path in the current session.
+ *
+ * @return A {@link GeckoResult} that is resolved when the page is fully loaded.
+ */
+ public GeckoResult<Void> loadTestPath(final String path) {
+ return loadUri(GeckoSessionTestRule.TEST_ENDPOINT + path);
+ }
+
+ /**
+ * Load an arbitrary URI in the current session.
+ *
+ * @return A {@link GeckoResult} that is resolved when the page is fully loaded.
+ */
+ public GeckoResult<Void> loadUri(final String uri) {
+ return started.then(
+ unused -> {
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putString("uri", uri);
+ sendMessage(MESSAGE_LOAD_URI, data);
+ return waitForPageStop();
+ });
+ }
+ }
+}
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..99cc30cd38
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt
@@ -0,0 +1,1407 @@
+/* -*- 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.content.ClipDescription
+import android.net.Uri
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+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 android.view.inputmethod.InputContentInfo
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.TextInputDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+@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 : 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 : 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 : 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) {
+ }
+
+ @AssertCalled(count = 0)
+ override fun hideSoftInput(session: GeckoSession) {
+ }
+ })
+ }
+
+ @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 : 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 : 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 : 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("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+
+ commitText(ic, "foobarfoo", 1)
+ assertTextAndSelectionAt("Set initial text and selection", ic, "foobarfoo", 9)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ 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)),
+ )
+ }
+
+ @WithDisplay(width = 512, height = 512)
+ // Child process updates require having a display.
+ @Test
+ fun inputConnection_selectionByArrowKey() {
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Set initial text", ic, "")
+
+ commitText(ic, "foo", 1) // Selection at end of new text
+ assertTextAndSelectionAt("Commit foo text", ic, "foo", 3)
+
+ // backward selection test
+ var time = SystemClock.uptimeMillis()
+ var shiftKey = KeyEvent(
+ time,
+ time,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_SHIFT_LEFT,
+ 0,
+ )
+ ic.sendKeyEvent(shiftKey)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP))
+ // No way to get notification for selection on Java side. So sync shadow text
+ syncShadowText(ic)
+ assertSelection("Set backward select using key event", ic, 3, 0)
+
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT)
+ // No way to get notification for selection on Java side. So sync shadow text
+ syncShadowText(ic)
+ assertSelectionAt("Reset selection using key event", ic, 0)
+
+ // forward selection test
+ time = SystemClock.uptimeMillis()
+ shiftKey = KeyEvent(
+ time,
+ time,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_SHIFT_LEFT,
+ 0,
+ )
+ ic.sendKeyEvent(shiftKey)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_RIGHT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_RIGHT)
+ pressKey(ic, KeyEvent.KEYCODE_DPAD_RIGHT)
+ ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP))
+ // No way to get notification for selection on Java side. So sync shadow text
+ syncShadowText(ic)
+ assertSelection("Set forward select using key event", ic, 0, 3)
+ }
+
+ // 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
+
+ // TODO(m_kato)
+ // Since geckoview-junit doesn't attach View, there is no way to wait for correct selection data.
+ // So Sync shadow text to avoid failures.
+ syncShadowText(ic)
+ 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,
+ )
+ }
+
+ // Test for setting large text on text box.
+ @WithDisplay(width = 512, height = 512)
+ // Child process updates require having a display.
+ @Test
+ fun inputConnection_largeText() {
+ val content = (1..102400).map {
+ ('a'..'z').random()
+ }.joinToString("")
+ setupContent(content)
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Can set large initial text", ic, content, /* checkGecko */ false)
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N_MR1)
+ @WithDisplay(width = 512, height = 512)
+ // Child process updates require having a display.
+ @Test
+ fun inputConnection_commitContent() {
+ if (id == "#input" || id == "#textarea") {
+ assertThat(
+ "This test is only for contenteditable or designmode",
+ true,
+ equalTo(true),
+ )
+ return
+ }
+
+ setupContent("")
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Set initial text", ic, "")
+
+ val promise = mainSession.evaluatePromiseJS(
+ when (id) {
+ "#designmode" -> """
+ new Promise((resolve, reject) => document.querySelector('$id').contentDocument.addEventListener('input', e => {
+ if (e.inputType == 'insertFromPaste') {
+ resolve();
+ } else {
+ reject();
+ }
+ }, { once: true }))
+ """.trimIndent()
+ else -> """
+ new Promise((resolve, reject) => document.querySelector('$id').addEventListener('input', e => {
+ if (e.inputType == 'insertFromPaste') {
+ resolve();
+ } else {
+ reject();
+ }
+ }, { once: true }))
+ """.trimIndent()
+ },
+ )
+
+ // InputContentInfo requires content:// uri, so we have to set test data to custom content provider.
+ TestContentProvider.setTestData(this.getTestBytes("/assets/www/images/test.gif"), "image/gif")
+ val info = InputContentInfo(Uri.parse("content://org.mozilla.geckoview.test.provider/gif"), ClipDescription("test", arrayOf("image/gif")))
+ ic.commitContent(info, 0, null)
+ promise.value
+ assertThat("Input event is fired by inserting image", true, equalTo(true))
+ }
+
+ // 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 sendDummyKeyboardEvent() {
+ // unnecessary for designmode
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ setupContent("")
+
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+ assertText("Set initial text", ic, "")
+
+ commitText(ic, "foo", 1)
+ assertTextAndSelectionAt("commit text and selection", ic, "foo", 3)
+
+ // 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, 3)
+ 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_defaultByInputType() {
+ assumeThat("type attribute is input element only", id, equalTo("#input"))
+ // Disable this with WebRender due to unexpected abort by mozilla::gl::GLContext::fTexSubImage2D
+ // (Bug 1706688, Bug 1710060 and etc)
+ assumeThat(sessionRule.env.isWebrender and sessionRule.env.isDebugBuild, equalTo(false))
+
+ mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext)
+ mainSession.loadTestPath(FORMS5_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ for (inputType in listOf("#email1", "#pass1", "#search1", "#tel1", "#url1")) {
+ mainSession.evaluateJS("document.querySelector('$inputType').focus()")
+ mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput")
+
+ // IC will be updated asynchronously, so spin event loop
+ processChildEvents()
+ processParentEvents()
+
+ val editorInfo = EditorInfo()
+ val ic = mainSession.textInput.onCreateInputConnection(editorInfo)!!
+ assertThat("InputConnection is created correctly", ic, notNullValue())
+
+ // Even if we get IC, new EditorInfo isn't updated yet.
+ // We post and wait for empty job to IC thread to flush all IC's job.
+ val result = object : GeckoResult<Boolean>() {
+ init {
+ val icHandler = mainSession.textInput.getHandler(Handler(Looper.getMainLooper()))
+ icHandler.post({
+ complete(true)
+ })
+ }
+ }
+ sessionRule.waitForResult(result)
+ mainSession.textInput.onCreateInputConnection(editorInfo)
+
+ assertThat(
+ "EditorInfo.inputType of $inputType",
+ editorInfo.inputType,
+ equalTo(
+ when (inputType) {
+ "#email1" ->
+ InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
+ "#pass1" ->
+ InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_VARIATION_PASSWORD
+ "#search1" ->
+ InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or
+ InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE or
+ InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
+ "#tel1" -> InputType.TYPE_CLASS_PHONE
+ "#url1" ->
+ InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_VARIATION_URI
+ else -> 0
+ },
+ ),
+ )
+ }
+ }
+
+ @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",
+ )
+ }
+
+ @WithDisplay(width = 512, height = 512)
+ // Child process updates require having a display.
+ @Test
+ fun inputConnection_bug1650705() {
+ // no way on designmode.
+ assumeThat("Not in designmode", id, not(equalTo("#designmode")))
+
+ setupContent("")
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+
+ commitText(ic, "foo", 1)
+ ic.setSelection(0, 3)
+
+ mainSession.evaluateJS(
+ """
+ input_event_count = 0;
+ document.querySelector('$id').addEventListener('input', () => {
+ input_event_count++;
+ })
+ """,
+ )
+
+ setComposingText(ic, "barbaz", 1)
+
+ val count = mainSession.evaluateJS("input_event_count") as Double
+ assertThat("input event is once", count, equalTo(1.0))
+
+ finishComposingText(ic)
+ }
+
+ @WithDisplay(width = 512, height = 512)
+ // Child process updates require having a display.
+ @Test
+ fun inputConnection_bug1767556() {
+ setupContent("")
+ val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!!
+
+ // Emulate GBoard's InputConnection API calls
+ ic.beginBatchEdit()
+ ic.setComposingText("fooba", 1)
+ ic.endBatchEdit()
+ ic.setComposingText("fooba", 1)
+ processChildEvents()
+
+ ic.beginBatchEdit()
+ ic.setComposingText("foobaz", 1)
+ ic.endBatchEdit()
+ ic.setComposingText("foobaz", 1)
+ processChildEvents()
+
+ ic.beginBatchEdit()
+ ic.setComposingText("foobaz1", 1)
+ ic.endBatchEdit()
+ ic.setComposingText("foobaz1", 1)
+ processChildEvents()
+
+ finishComposingText(ic)
+ assertText("commit foobaz1", ic, "foobaz1")
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java
new file mode 100644
index 0000000000..141849589e
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java
@@ -0,0 +1,119 @@
+package org.mozilla.geckoview.test;
+
+import android.content.Context;
+import android.content.Intent;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.File;
+import java.util.List;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate;
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission;
+import org.mozilla.geckoview.GeckoSessionSettings;
+
+public class TrackingPermissionService extends TestRuntimeService {
+ public static final int MESSAGE_SET_TRACKING_PERMISSION = FIRST_SAFE_MESSAGE + 1;
+ public static final int MESSAGE_SET_PRIVATE_BROWSING_TRACKING_PERMISSION = FIRST_SAFE_MESSAGE + 2;
+ public static final int MESSAGE_GET_TRACKING_PERMISSION = FIRST_SAFE_MESSAGE + 3;
+
+ private ContentPermission mContentPermission;
+
+ @Override
+ protected GeckoSession createSession(final Intent intent) {
+ return new GeckoSession(
+ new GeckoSessionSettings.Builder()
+ .usePrivateMode(mTestData.getBoolean("privateMode"))
+ .build());
+ }
+
+ @Override
+ protected void onSessionReady(final GeckoSession session) {
+ session.setNavigationDelegate(
+ new GeckoSession.NavigationDelegate() {
+ @Override
+ public void onLocationChange(
+ final @NonNull GeckoSession session,
+ final @Nullable String url,
+ final @NonNull List<ContentPermission> perms) {
+ for (ContentPermission perm : perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_TRACKING) {
+ mContentPermission = perm;
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ protected GeckoResult<GeckoBundle> handleMessage(final int messageId, final GeckoBundle data) {
+ if (mContentPermission == null) {
+ throw new IllegalStateException("Content permission not received yet!");
+ }
+
+ switch (messageId) {
+ case MESSAGE_SET_TRACKING_PERMISSION:
+ {
+ final int permission = data.getInt("trackingPermission");
+ mRuntime.getStorageController().setPermission(mContentPermission, permission);
+ break;
+ }
+ case MESSAGE_SET_PRIVATE_BROWSING_TRACKING_PERMISSION:
+ {
+ final int permission = data.getInt("trackingPermission");
+ mRuntime
+ .getStorageController()
+ .setPrivateBrowsingPermanentPermission(mContentPermission, permission);
+ break;
+ }
+ case MESSAGE_GET_TRACKING_PERMISSION:
+ {
+ final GeckoBundle result = new GeckoBundle(1);
+ result.putInt("trackingPermission", mContentPermission.value);
+ return GeckoResult.fromValue(result);
+ }
+ }
+
+ return null;
+ }
+
+ public static class TrackingPermissionInstance
+ extends RuntimeInstance<TrackingPermissionService> {
+ public static GeckoBundle testData(boolean privateMode) {
+ GeckoBundle testData = new GeckoBundle(1);
+ testData.putBoolean("privateMode", privateMode);
+ return testData;
+ }
+
+ private TrackingPermissionInstance(
+ final Context context, final File profileFolder, final boolean privateMode) {
+ super(context, TrackingPermissionService.class, profileFolder, testData(privateMode));
+ }
+
+ public static TrackingPermissionInstance start(
+ final Context context, final File profileFolder, final boolean privateMode) {
+ TrackingPermissionInstance instance =
+ new TrackingPermissionInstance(context, profileFolder, privateMode);
+ instance.sendIntent();
+ return instance;
+ }
+
+ public GeckoResult<Integer> getTrackingPermission() {
+ return query(MESSAGE_GET_TRACKING_PERMISSION)
+ .map(bundle -> bundle.getInt("trackingPermission"));
+ }
+
+ public void setTrackingPermission(final int permission) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putInt("trackingPermission", permission);
+ sendMessage(MESSAGE_SET_TRACKING_PERMISSION, bundle);
+ }
+
+ public void setPrivateBrowsingPermanentTrackingPermission(final int permission) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putInt("trackingPermission", permission);
+ sendMessage(MESSAGE_SET_PRIVATE_BROWSING_TRACKING_PERMISSION, bundle);
+ }
+ }
+}
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..2e340c09c2
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt
@@ -0,0 +1,88 @@
+/* -*- 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.* // ktlint-disable no-wildcard-imports
+import android.graphics.Bitmap
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers
+import org.hamcrest.Matchers.equalTo
+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.GeckoSession
+import org.mozilla.geckoview.GeckoSession.ContentDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+
+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)
+ mainSession.loadTestPath(FIXED_BOTTOM)
+ sessionRule.waitUntilCalled(object : ContentDelegate {
+ @AssertCalled(count = 1)
+ override fun onFirstContentfulPaint(session: GeckoSession) {
+ }
+ })
+
+ sessionRule.display?.let {
+ assertScreenshotResult(it.capturePixels(), getComparisonScreenshot(45))
+ }
+ }
+}
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..3f4af40a0b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt
@@ -0,0 +1,545 @@
+/* -*- 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.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mozilla.gecko.util.ThreadUtils
+import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports
+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.* // ktlint-disable no-wildcard-imports
+
+@MediumTest
+@RunWith(Parameterized::class)
+class WebExecutorTest {
+ companion object {
+ const val TEST_PORT: Int = 4242
+ const val TEST_ENDPOINT: String = "http://localhost:$TEST_PORT"
+
+ @get:Parameterized.Parameters(name = "{0}")
+ @JvmStatic
+ val parameters: List<Array<out Any>> = listOf(
+ arrayOf("#conservative"),
+ arrayOf("#normal"),
+ )
+ }
+
+ @field:Parameterized.Parameter(0)
+ @JvmField
+ var id: String = ""
+
+ lateinit var executor: GeckoWebExecutor
+ lateinit var server: TestServer
+
+ @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()
+ }
+
+ fun webRequestBuilder(uri: String): WebRequest.Builder {
+ val beConservative = when (id) {
+ "#conservative" -> true
+ else -> false
+ }
+ return WebRequest.Builder(uri).beConservative(beConservative)
+ }
+
+ fun webRequest(uri: String): WebRequest {
+ return webRequestBuilder(uri).build()
+ }
+
+ @Test
+ fun smoke() {
+ val uri = "$TEST_ENDPOINT/anything"
+ val bodyString = randomString(8192)
+ val referrer = "http://foo/bar"
+
+ val request = webRequestBuilder(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("http://foo/"))
+ 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() {
+ val thrown = assertThrows(WebRequestError::class.java) {
+ fetch(webRequest("$TEST_ENDPOINT/redirect/100"))
+ }
+ assertThat(thrown, equalTo(WebRequestError(WebRequestError.ERROR_REDIRECT_LOOP, WebRequestError.ERROR_CATEGORY_NETWORK)))
+ }
+
+ @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 clearData = GeckoResult<Void>()
+ ThreadUtils.runOnUiThread {
+ clearData.completeFrom(
+ RuntimeCreator.getRuntime()
+ .storageController
+ .clearData(StorageController.ClearFlags.ALL),
+ )
+ }
+
+ clearData.pollDefault()
+
+ 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() {
+ val thrown = assertThrows(WebRequestError::class.java) {
+ fetch(webRequest("https://this.should.not.resolve"))
+ }
+ assertThat(thrown, equalTo(WebRequestError(WebRequestError.ERROR_UNKNOWN_HOST, WebRequestError.ERROR_CATEGORY_URI)))
+ }
+
+ @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"),
+ )
+ }
+ }
+
+ val legal = listOf(
+ "http://$TEST_ENDPOINT\n",
+ "http://$TEST_ENDPOINT/🥲",
+ "http://$TEST_ENDPOINT/abc",
+ )
+
+ for (uri in legal) {
+ try {
+ fetch(webRequest(uri))
+ throw IllegalStateException("fetch() should have thrown")
+ } catch (e: WebRequestError) {
+ assertThat(
+ "Request should pass initial validation.",
+ true,
+ equalTo(true),
+ )
+ }
+ }
+ }
+}
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..65952ecfb2
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt
@@ -0,0 +1,2989 @@
+/* -*- 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.MatcherAssert.assertThat
+import org.hamcrest.core.IsEqual.equalTo
+import org.hamcrest.core.StringEndsWith.endsWith
+import org.json.JSONObject
+import org.junit.Assert.* // ktlint-disable no-wildcard-imports
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate
+import org.mozilla.geckoview.WebExtension.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.WebExtension.BrowsingDataDelegate.Type.* // ktlint-disable no-wildcard-imports
+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.RejectedPromiseException
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.Setting
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.RuntimeCreator
+import org.mozilla.geckoview.test.util.UiThreadUtils
+import java.nio.charset.Charset
+import java.util.* // ktlint-disable no-wildcard-imports
+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.setPrefsUntilTestEnd(mapOf("extensions.isembedded" to true))
+ sessionRule.runtime.webExtensionController.setTabActive(mainSession, true)
+ }
+
+ @Test
+ fun installBuiltIn() {
+ mainSession.loadUri("https://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("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.allow()
+ }
+ })
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtensionController.AddonManagerDelegate::class,
+ { delegate -> controller.setAddonManagerDelegate(delegate) },
+ { controller.setAddonManagerDelegate(null) },
+ object : WebExtensionController.AddonManagerDelegate {
+ @AssertCalled(count = 3)
+ override fun onEnabling(extension: WebExtension) {}
+
+ @AssertCalled(count = 3)
+ override fun onEnabled(extension: WebExtension) {}
+
+ @AssertCalled(count = 3)
+ override fun onDisabling(extension: WebExtension) {}
+
+ @AssertCalled(count = 3)
+ override fun onDisabled(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onUninstalling(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onUninstalled(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onInstalling(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onInstalled(extension: WebExtension) {}
+ },
+ )
+
+ // 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("https://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.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("https://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.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.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.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.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.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("https://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.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.delegateUntilTestEnd(object : WebNotificationDelegate {
+ @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, "https://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() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("privacy.userContext.enabled" to true))
+ 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(mainSession.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(mainSession.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(mainSession, 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.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.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.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(mainSession.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(mainSession, 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),
+ )
+
+ mainSession.loadUri("${extension.metaData.baseUrl}tab.html")
+ sessionRule.waitForPageStop()
+
+ var savedState: GeckoSession.SessionState? = null
+ sessionRule.waitUntilCalled(object : 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, mainSession)
+
+ 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),
+ )
+ mainSession.webExtensionController
+ .setMessageDelegate(webExtension, messageDelegate, "browser")
+ }
+
+ return webExtension
+ }
+
+ @Test
+ fun contentMessaging() {
+ mainSession.loadUri("https://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("https://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
+ mainSession.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("https://example.com")
+ sessionRule.waitForPageStop()
+ testPortDisconnect(background = false, refresh = false)
+ }
+
+ @Test
+ fun backgroundPortDisconnect() {
+ testPortDisconnect(background = true, refresh = false)
+ }
+
+ @Test
+ fun contentPortDisconnectAfterRefresh() {
+ mainSession.loadUri("https://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, "https://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("https://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/",
+ ),
+ )
+ mainSession.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.allow()
+ }
+ },
+ )
+
+ mainSession.loadUri("https://example.com")
+
+ mainSession.waitUntilCalled(object : NavigationDelegate, ProgressDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ assertThat(
+ "Url should load example.com first",
+ url,
+ equalTo("https://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 : NavigationDelegate, ProgressDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ 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 web extension permission.request.
+ @WithDisplay(width = 100, height = 100)
+ @Test
+ fun permissionRequest() {
+ sessionRule.setPrefsUntilTestEnd(
+ mapOf(
+ "xpinstall.signatures.required" to false,
+ "extensions.install.requireBuiltInCerts" to false,
+ "extensions.update.requireBuiltInCerts" to false,
+ ),
+ )
+
+ val extension = sessionRule.waitForResult(
+ controller.ensureBuiltIn(
+ "resource://android/assets/web_extensions/permission-request/",
+ "permissions@example.com",
+ ),
+ )
+
+ mainSession.loadUri("${extension.metaData.baseUrl}clickToRequestPermission.html")
+ sessionRule.waitForPageStop()
+
+ // click triggers permissions.request
+ mainSession.synthesizeTap(50, 50)
+
+ sessionRule.delegateUntilTestEnd(object : WebExtensionController.PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onOptionalPrompt(extension: WebExtension, permissions: Array<String>, origins: Array<String>): GeckoResult<AllowOrDeny> {
+ val expected = arrayOf("geolocation")
+ assertThat("Permissions should match the requested permissions", permissions, equalTo(expected))
+ assertThat("Origins should match the requested origins", origins, equalTo(arrayOf("*://example.com/*")))
+ return forEachCall(GeckoResult.deny(), GeckoResult.allow())
+ }
+ })
+
+ var result = GeckoResult<String>()
+ mainSession.webExtensionController.setMessageDelegate(
+ extension,
+ object : WebExtension.MessageDelegate {
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult<Any>? {
+ result.complete(message as String)
+ return null
+ }
+ },
+ "browser",
+ )
+
+ val message = sessionRule.waitForResult(result)
+ assertThat("Permission request should first be denied.", message, equalTo("false"))
+
+ mainSession.synthesizeTap(50, 50)
+ result = GeckoResult<String>()
+ val message2 = sessionRule.waitForResult(result)
+ assertThat("Permission request should be accepted.", message2, equalTo("true"))
+
+ mainSession.synthesizeTap(50, 50)
+ result = GeckoResult<String>()
+ val message3 = sessionRule.waitForResult(result)
+ assertThat("Permission request should already be accepted.", message3, equalTo("true"))
+
+ sessionRule.waitForResult(controller.uninstall(extension))
+ }
+
+ // 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("https://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.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("https://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.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.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("https://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.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("https://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.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.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.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("https://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.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("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.allow()
+ }
+ })
+
+ sessionRule.addExternalDelegateUntilTestEnd(
+ WebExtensionController.AddonManagerDelegate::class,
+ { delegate -> controller.setAddonManagerDelegate(delegate) },
+ { controller.setAddonManagerDelegate(null) },
+ object : WebExtensionController.AddonManagerDelegate {
+ @AssertCalled(count = 0)
+ override fun onEnabling(extension: WebExtension) {}
+
+ @AssertCalled(count = 0)
+ override fun onEnabled(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onDisabling(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onDisabled(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onUninstalling(extension: WebExtension) {}
+
+ @AssertCalled(count = 1)
+ override fun onUninstalled(extension: WebExtension) {}
+
+ // We expect onInstalling/onInstalled to be invoked twice
+ // because we first install the extension and then we update
+ // it, which results in a second install.
+ @AssertCalled(count = 2)
+ override fun onInstalling(extension: WebExtension) {}
+
+ @AssertCalled(count = 2)
+ override fun onInstalled(extension: WebExtension) {}
+ },
+ )
+
+ 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("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.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<DownloadInitData>? {
+ 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)
+
+ val downloadInfo = object : Download.Info {}
+
+ val initialData = DownloadInitData(download, downloadInfo)
+ return GeckoResult.fromValue(initialData)
+ }
+ }
+
+ 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("https://example.com")
+ sessionRule.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ return GeckoResult.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<DownloadInitData>? {
+ 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)
+
+ val downloadInfo = object : Download.Info {}
+
+ val initialData = DownloadInitData(download, downloadInfo)
+ return GeckoResult.fromValue(initialData)
+ }
+ }
+
+ webExtension.setDownloadDelegate(downloadDelegate)
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled)
+ assertNotNull(downloadCreated.id)
+ sessionRule.waitForResult(controller.uninstall(webExtension))
+ }
+
+ @Test
+ fun testOnChanged() {
+ val uri = createTestUrl("/assets/www/images/test.gif")
+ val downloadId = 4
+ val unfinishedDownloadSize = 5L
+ val finishedDownloadSize = 25L
+ val expectedFilename = "test.gif"
+ val expectedMime = "image/gif"
+ val expectedEndTime = Date().time
+ val expectedFilesize = 48L
+
+ // first and second update
+ val downloadData = object : Download.Info {
+ var endTime: Long? = null
+ val startTime = Date().time - 50000
+ var fileExists = false
+ var totalBytes: Long = -1
+ var mime = ""
+ var fileSize: Long = -1
+ var filename = ""
+ var state = Download.STATE_IN_PROGRESS
+
+ override fun state(): Int {
+ return state
+ }
+
+ override fun endTime(): Long? {
+ return endTime
+ }
+
+ override fun startTime(): Long {
+ return startTime
+ }
+
+ override fun fileExists(): Boolean {
+ return fileExists
+ }
+
+ override fun totalBytes(): Long {
+ return totalBytes
+ }
+
+ override fun mime(): String {
+ return mime
+ }
+
+ override fun fileSize(): Long {
+ return fileSize
+ }
+
+ override fun filename(): String {
+ return filename
+ }
+ }
+
+ val webExtension = sessionRule.waitForResult(
+ controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"),
+ )
+
+ val assertOnDownloadCalled = GeckoResult<Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? {
+ assertEquals(webExtension!!.id, source.id)
+ assertEquals(uri, request.request.uri)
+
+ val download = controller.createDownload(downloadId)
+ assertOnDownloadCalled.complete(download)
+ return GeckoResult.fromValue(DownloadInitData(download, downloadData))
+ }
+ }
+
+ val updates = mutableListOf<JSONObject>()
+
+ val thirdUpdateReceived = GeckoResult<JSONObject>()
+ val messageDelegate = object : MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? {
+ val current = (message as JSONObject).getJSONObject("current")
+
+ updates.add(message)
+
+ // Once we get the size finished download, that means we got the last update
+ if (current.getLong("totalBytes") == finishedDownloadSize) {
+ thirdUpdateReceived.complete(message)
+ }
+
+ return GeckoResult.fromValue(message)
+ }
+ }
+
+ webExtension.setDownloadDelegate(downloadDelegate)
+ webExtension.setMessageDelegate(messageDelegate, "browser")
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled)
+ assertEquals(downloadId, downloadCreated.id)
+
+ // first and second update (they are identical)
+ downloadData.filename = expectedFilename
+ downloadData.mime = expectedMime
+ downloadData.totalBytes = unfinishedDownloadSize
+
+ downloadCreated.update(downloadData)
+ downloadCreated.update(downloadData)
+
+ downloadData.fileSize = expectedFilesize
+ downloadData.endTime = expectedEndTime
+ downloadData.totalBytes = finishedDownloadSize
+ downloadData.state = Download.STATE_COMPLETE
+ downloadCreated.update(downloadData)
+
+ sessionRule.waitForResult(thirdUpdateReceived)
+
+ // The second update should not be there because the data was identical
+ assertEquals(2, updates.size)
+
+ val firstUpdateCurrent = updates[0].getJSONObject("current")
+ val firstUpdatePrevious = updates[0].getJSONObject("previous")
+ assertEquals(3, firstUpdateCurrent.length())
+ assertEquals(3, firstUpdatePrevious.length())
+ assertEquals(expectedMime, firstUpdateCurrent.getString("mime"))
+ assertEquals("", firstUpdatePrevious.getString("mime"))
+ assertEquals(expectedFilename, firstUpdateCurrent.getString("filename"))
+ assertEquals("", firstUpdatePrevious.getString("filename"))
+ assertEquals(unfinishedDownloadSize, firstUpdateCurrent.getLong("totalBytes"))
+ assertEquals(-1, firstUpdatePrevious.getLong("totalBytes"))
+
+ val secondUpdateCurrent = updates[1].getJSONObject("current")
+ val secondUpdatePrevious = updates[1].getJSONObject("previous")
+ assertEquals(4, secondUpdateCurrent.length())
+ assertEquals(4, secondUpdatePrevious.length())
+ assertEquals(finishedDownloadSize, secondUpdateCurrent.getLong("totalBytes"))
+ assertEquals(firstUpdateCurrent.getLong("totalBytes"), secondUpdatePrevious.getLong("totalBytes"))
+ assertEquals("complete", secondUpdateCurrent.get("state").toString())
+ assertEquals("in_progress", secondUpdatePrevious.get("state").toString())
+ assertEquals(expectedEndTime.toString(), secondUpdateCurrent.getString("endTime"))
+ assertEquals("null", secondUpdatePrevious.getString("endTime"))
+ assertEquals(expectedFilesize, secondUpdateCurrent.getLong("fileSize"))
+ assertEquals(-1, secondUpdatePrevious.getLong("fileSize"))
+
+ sessionRule.waitForResult(controller.uninstall(webExtension))
+ }
+
+ @Test
+ fun testOnChangedWrongId() {
+ val uri = createTestUrl("/assets/www/images/test.gif")
+ val downloadId = 5
+
+ val webExtension = sessionRule.waitForResult(
+ controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"),
+ )
+
+ val assertOnDownloadCalled = GeckoResult<WebExtension.Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? {
+ assertEquals(webExtension!!.id, source.id)
+ assertEquals(uri, request.request.uri)
+
+ val download = controller.createDownload(downloadId)
+ assertOnDownloadCalled.complete(download)
+ return GeckoResult.fromValue(DownloadInitData(download, object : Download.Info {}))
+ }
+ }
+
+ val onMessageCalled = GeckoResult<String>()
+ val messageDelegate = object : MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? {
+ onMessageCalled.complete(message as String)
+ return GeckoResult.fromValue(message)
+ }
+ }
+
+ webExtension.setDownloadDelegate(downloadDelegate)
+ webExtension.setMessageDelegate(messageDelegate, "browser")
+
+ mainSession.reload()
+ sessionRule.waitForPageStop()
+
+ val updateData = object : WebExtension.Download.Info {
+ override fun state(): Int {
+ return WebExtension.Download.STATE_COMPLETE
+ }
+ }
+
+ val randomDownload = controller.createDownload(25)
+
+ val r = randomDownload!!.update(updateData)
+
+ try {
+ sessionRule.waitForResult(r!!)
+ } catch (ex: Exception) {
+ val a = ex.message!!
+ assertEquals("Error: Trying to update unknown download", a)
+ sessionRule.waitForResult(controller.uninstall(webExtension))
+ return
+ }
+ }
+}
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..469fd049ce
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt
@@ -0,0 +1,386 @@
+package org.mozilla.geckoview.test
+
+import android.os.Parcel
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+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.PermissionDelegate
+import org.mozilla.geckoview.WebNotification
+import org.mozilla.geckoview.WebNotificationDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+
+const val VERY_LONG_IMAGE_URL = "https://example.com/this/is/a/very/long/address/that/is/meant/to/be/longer/than/is/one/hundred/and/fifth/characters/long/for/testing/imageurl/length.ico"
+
+@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 : PermissionDelegate {
+ override fun onContentPermissionRequest(session: GeckoSession, perm: PermissionDelegate.ContentPermission):
+ GeckoResult<Int>? {
+ assertThat("Should grant DESKTOP_NOTIFICATIONS permission", perm.permission, equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+ return GeckoResult.fromValue(PermissionDelegate.ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ val result = mainSession.waitForJS("Notification.requestPermission()")
+ assertThat(
+ "Permission should be granted",
+ result as String,
+ equalTo("granted"),
+ )
+ }
+
+ @Test fun onSilentNotification() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.silent.enabled" to true))
+ val notificationResult = GeckoResult<Void>()
+
+ sessionRule.delegateDuringNextWait(object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ assertThat("Title should match", notification.title, equalTo("The Title"))
+ assertThat("Silent should match", notification.silent, equalTo(true))
+ assertThat("Vibrate should match", notification.vibrate, equalTo(intArrayOf()))
+ assertThat("Source should match", notification.source, equalTo(createTestUrl(HELLO_HTML_PATH)))
+ notificationResult.complete(null)
+ }
+ })
+
+ mainSession.evaluateJS(
+ """
+ new Notification('The Title', { body: 'The Text', silent: true });
+ """.trimIndent(),
+ )
+
+ sessionRule.waitForResult(notificationResult)
+ }
+
+ fun assertNotificationData(notification: WebNotification, requireInteraction: Boolean) {
+ 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("Vibrate should match", notification.vibrate, equalTo(intArrayOf(1, 2, 3, 4)))
+ assertThat("Silent should match", notification.silent, equalTo(false))
+ assertThat("Source should match", notification.source, equalTo(createTestUrl(HELLO_HTML_PATH)))
+ }
+
+ @GeckoSessionTestRule.Setting.List(
+ GeckoSessionTestRule.Setting(
+ key = GeckoSessionTestRule.Setting.Key.USE_PRIVATE_MODE,
+ value = "true",
+ ),
+ )
+ @Ignore // Bug 1843046 - Disabled because private notifications are temporarily disabled.
+ @Test
+ fun onShowNotification() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.vibrate.enabled" to true))
+ val notificationResult = GeckoResult<Void>()
+ val requireInteraction =
+ sessionRule.getPrefs("dom.webnotifications.requireinteraction.enabled")[0] as Boolean
+
+ sessionRule.delegateDuringNextWait(object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ assertNotificationData(notification, requireInteraction)
+ assertThat("privateBrowsing should match", notification.privateBrowsing, equalTo(true))
+ 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, vibrate: [1,2,3,4] });
+ """.trimIndent(),
+ )
+
+ sessionRule.waitForResult(notificationResult)
+ }
+
+ @Test fun onCloseNotification() {
+ val closeCalled = GeckoResult<Void>()
+
+ sessionRule.delegateDuringNextWait(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 clickNotificationParceled() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.vibrate.enabled" to true))
+ val notificationResult = GeckoResult<WebNotification>()
+ val requireInteraction =
+ sessionRule.getPrefs("dom.webnotifications.requireinteraction.enabled")[0] as Boolean
+
+ sessionRule.delegateDuringNextWait(object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationResult.complete(notification)
+ }
+ })
+
+ val promiseResult = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ const notification = new Notification('The Title', {
+ body: 'The Text',
+ cookie: 'Cookie',
+ icon: 'icon.png',
+ tag: 'Tag',
+ dir: 'ltr',
+ lang: 'en-US',
+ requireInteraction: true,
+ vibrate: [1,2,3,4]
+ });
+ notification.onclick = function() {
+ resolve(1);
+ }
+ });
+ """.trimIndent(),
+ )
+
+ val notification = sessionRule.waitForResult(notificationResult)
+ assertNotificationData(notification, requireInteraction)
+ assertThat("privateBrowsing should match", notification.privateBrowsing, equalTo(false))
+
+ // Test that we can click from a deserialized notification
+ val parcel = Parcel.obtain()
+ notification.writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+
+ val deserialized = WebNotification.CREATOR.createFromParcel(parcel)
+ assertNotificationData(deserialized, requireInteraction)
+ assertThat("privateBrowsing should match", deserialized.privateBrowsing, equalTo(false))
+
+ deserialized!!.click()
+ assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0))
+ }
+
+ @GeckoSessionTestRule.Setting.List(
+ GeckoSessionTestRule.Setting(
+ key = GeckoSessionTestRule.Setting.Key.USE_PRIVATE_MODE,
+ value = "true",
+ ),
+ )
+ @Ignore // Bug 1843046 - Disabled because private notifications are temporarily disabled.
+ @Test
+ fun clickPrivateNotificationParceled() {
+ sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.vibrate.enabled" to true))
+ val notificationResult = GeckoResult<WebNotification>()
+ val requireInteraction =
+ sessionRule.getPrefs("dom.webnotifications.requireinteraction.enabled")[0] as Boolean
+
+ sessionRule.delegateDuringNextWait(object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationResult.complete(notification)
+ }
+ })
+
+ val promiseResult = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ const notification = new Notification('The Title', {
+ body: 'The Text',
+ cookie: 'Cookie',
+ icon: 'icon.png',
+ tag: 'Tag',
+ dir: 'ltr',
+ lang: 'en-US',
+ requireInteraction: true,
+ vibrate: [1,2,3,4]
+ });
+ notification.onclick = function() {
+ resolve(1);
+ }
+ });
+ """.trimIndent(),
+ )
+
+ val notification = sessionRule.waitForResult(notificationResult)
+ assertNotificationData(notification, requireInteraction)
+ assertThat("privateBrowsing should match", notification.privateBrowsing, equalTo(true))
+
+ // Test that we can click from a deserialized notification
+ val parcel = Parcel.obtain()
+ notification.writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+
+ val deserialized = WebNotification.CREATOR.createFromParcel(parcel)
+ assertNotificationData(deserialized, requireInteraction)
+ assertThat("privateBrowsing should match", deserialized.privateBrowsing, equalTo(true))
+
+ deserialized!!.click()
+ assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0))
+ }
+
+ @Test fun clickNotification() {
+ val notificationResult = GeckoResult<Void>()
+ var notificationShown: WebNotification? = null
+
+ sessionRule.delegateDuringNextWait(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 notificationResult = GeckoResult<Void>()
+ var notificationShown: WebNotification? = null
+
+ sessionRule.delegateDuringNextWait(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))
+ }
+
+ @Test fun writeToParcel() {
+ val notificationResult = GeckoResult<WebNotification>()
+
+ sessionRule.delegateDuringNextWait(object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationResult.complete(notification)
+ }
+ })
+
+ val promiseResult = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ const notification = new Notification('The Title', { body: 'The Text' });
+ notification.onclose = function() {
+ resolve(1);
+ }
+ });
+ """.trimIndent(),
+ )
+
+ val notification = sessionRule.waitForResult(notificationResult)
+ notification.dismiss()
+
+ // Ensure we always have a non-null URL from js.
+ assertNotNull(notification.imageUrl)
+
+ // Test that we can serialize a notification
+ val parcel = Parcel.obtain()
+ notification.writeToParcel(parcel, /* ignored */ -1)
+
+ assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0))
+ }
+
+ @Test fun writeToParcelLongImageUrl() {
+ val notificationResult = GeckoResult<WebNotification>()
+
+ sessionRule.delegateDuringNextWait(object : WebNotificationDelegate {
+ @GeckoSessionTestRule.AssertCalled
+ override fun onShowNotification(notification: WebNotification) {
+ notificationResult.complete(notification)
+ }
+ })
+
+ val promiseResult = mainSession.evaluatePromiseJS(
+ """
+ new Promise(resolve => {
+ const notification = new Notification('The Title',
+ {
+ body: 'The Text',
+ icon: '$VERY_LONG_IMAGE_URL'
+ });
+ notification.onclose = function() {
+ resolve(1);
+ }
+ });
+ """.trimIndent(),
+ )
+
+ val notification = sessionRule.waitForResult(notificationResult)
+ notification.dismiss()
+
+ // Ensure we have an imageUrl longer than our max to start with.
+ assertNotNull(notification.imageUrl)
+ assertTrue(notification.imageUrl!!.length > 150)
+
+ // Test that we can serialize a notification with an imageUrl.length >= 150
+ val parcel = Parcel.obtain()
+ notification.writeToParcel(parcel, /* ignored */ -1)
+ parcel.setDataPosition(0)
+
+ val serializedNotification = WebNotification.CREATOR.createFromParcel(parcel)
+ assertTrue(serializedNotification.imageUrl!!.isBlank())
+
+ 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..a2e6d58f3a
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt
@@ -0,0 +1,257 @@
+/* -*- 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 android.util.Base64
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports
+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.* // ktlint-disable no-wildcard-imports
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException
+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 : PermissionDelegate {
+ override fun onContentPermissionRequest(session: GeckoSession, perm: GeckoSession.PermissionDelegate.ContentPermission):
+ GeckoResult<Int>? {
+ assertThat("Should grant DESKTOP_NOTIFICATIONS permission", perm.permission, equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+ return GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW)
+ }
+ })
+
+ delegate = TestPushDelegate()
+
+ sessionRule.delegateUntilTestEnd(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))
+ }
+
+ @Test
+ fun pushEventWithoutData() {
+ subscribe()
+
+ val p = mainSession.evaluatePromiseJS("window.doWaitForPushEvent()")
+
+ sessionRule.runtime.webPushController.onPushEvent(delegate!!.storedSubscription!!.scope, null)
+
+ assertThat("Push data should be empty", p.value as String, equalTo(""))
+ }
+
+ private fun sendNotification() {
+ val notificationResult = GeckoResult<Void>()
+ val expectedTitle = "The title"
+ val expectedBody = "The body"
+
+ sessionRule.delegateDuringNextWait(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..340025502e
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java
@@ -0,0 +1,165 @@
+/* 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.annotation.AnyThread;
+import androidx.annotation.Nullable;
+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 (final NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (final 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 (final NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (final 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..e19997dfc3
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt
@@ -0,0 +1,48 @@
+package org.mozilla.geckoview.test.crash
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.Matchers.equalTo
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoRuntime
+import org.mozilla.geckoview.test.BaseSessionTest
+import org.mozilla.geckoview.test.TestCrashHandler
+import org.mozilla.geckoview.test.TestRuntimeService
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ParentCrashTest : BaseSessionTest() {
+ private val targetContext
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+ private val timeout
+ get() = sessionRule.env.defaultTimeoutMillis
+
+ @Test
+ @ClosedSessionAtStart
+ fun crashParent() {
+ // TODO: Bug 1673956
+ assumeThat(sessionRule.env.isFission, equalTo(false))
+ val client = TestCrashHandler.Client(targetContext)
+
+ assertTrue(client.connect(timeout))
+ client.setEvalNextCrashDump(GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN)
+
+ val runtime = TestRuntimeService.RuntimeInstance.start(
+ targetContext,
+ RuntimeCrashTestService::class.java,
+ temporaryProfile.get(),
+ )
+ runtime.loadUri("about:crashparent")
+
+ val evalResult = client.getEvalResult(timeout)
+ assertTrue(evalResult.mMsg, evalResult.mResult)
+
+ client.disconnect()
+ }
+}
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt
new file mode 100644
index 0000000000..bfdc40621e
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt
@@ -0,0 +1,19 @@
+package org.mozilla.geckoview.test.crash
+
+import android.content.Context
+import android.content.Intent
+import org.mozilla.geckoview.GeckoRuntime
+import org.mozilla.geckoview.GeckoRuntimeSettings
+import org.mozilla.geckoview.test.TestCrashHandler
+import org.mozilla.geckoview.test.TestRuntimeService
+
+class RuntimeCrashTestService : TestRuntimeService() {
+ override fun createRuntime(context: Context, intent: Intent): GeckoRuntime {
+ return GeckoRuntime.create(
+ this.applicationContext,
+ GeckoRuntimeSettings.Builder()
+ .extras(intent.extras!!)
+ .crashHandler(TestCrashHandler::class.java).build(),
+ )
+ }
+}
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..81133bb063
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java
@@ -0,0 +1,2915 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.rule;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.fail;
+
+import android.app.Instrumentation;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Point;
+import android.graphics.SurfaceTexture;
+import android.location.Criteria;
+import android.location.Location;
+import android.location.LocationManager;
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.Pair;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.Surface;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.test.platform.app.InstrumentationRegistry;
+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.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import kotlin.jvm.JvmClassMappingKt;
+import kotlin.reflect.KClass;
+import org.hamcrest.Matcher;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+import org.junit.rules.ErrorCollector;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+import org.mozilla.gecko.MultiMap;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.Autocomplete;
+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.GeckoRuntime.ActivityDelegate;
+import org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.GeckoSession.ContentDelegate;
+import org.mozilla.geckoview.GeckoSession.HistoryDelegate;
+import org.mozilla.geckoview.GeckoSession.MediaDelegate;
+import org.mozilla.geckoview.GeckoSession.NavigationDelegate;
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate;
+import org.mozilla.geckoview.GeckoSession.PrintDelegate;
+import org.mozilla.geckoview.GeckoSession.ProgressDelegate;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate;
+import org.mozilla.geckoview.GeckoSession.ScrollDelegate;
+import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate;
+import org.mozilla.geckoview.GeckoSession.TextInputDelegate;
+import org.mozilla.geckoview.GeckoSessionSettings;
+import org.mozilla.geckoview.MediaSession;
+import org.mozilla.geckoview.OrientationController;
+import org.mozilla.geckoview.RuntimeTelemetry;
+import org.mozilla.geckoview.SessionTextInput;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.WebExtensionController;
+import org.mozilla.geckoview.WebNotificationDelegate;
+import org.mozilla.geckoview.WebPushDelegate;
+import org.mozilla.geckoview.test.GeckoViewTestActivity;
+import org.mozilla.geckoview.test.util.Environment;
+import org.mozilla.geckoview.test.util.RuntimeCreator;
+import org.mozilla.geckoview.test.util.TestServer;
+import org.mozilla.geckoview.test.util.UiThreadUtils;
+
+/**
+ * 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";
+
+ public static final int TEST_PORT = 4245;
+ public static final String TEST_HOST = "localhost";
+ public static final String TEST_ENDPOINT = "http://" + TEST_HOST + ":" + 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(new GeckoDisplay.SurfaceInfo.Builder(displaySurface).size(x, y).build());
+
+ mDisplays.put(session, display);
+ mDisplayTextures.put(session, displayTexture);
+ mDisplaySurfaces.put(session, displaySurface);
+ }
+
+ public void releaseDisplay(final GeckoSession session) {
+ if (!mDisplays.containsKey(session)) {
+ // No display to release
+ return;
+ }
+ final GeckoDisplay display = mDisplays.remove(session);
+ display.surfaceDestroyed();
+ session.releaseDisplay(display);
+ final Surface displaySurface = mDisplaySurfaces.remove(session);
+ displaySurface.release();
+ final SurfaceTexture displayTexture = mDisplayTextures.remove(session);
+ displayTexture.release();
+ }
+
+ /**
+ * Specify the timeout for any of the wait methods, in milliseconds, relative to {@link
+ * Environment#DEFAULT_TIMEOUT_MILLIS}. When the default timeout scales to account for differences
+ * in the device under test, the timeout value here will be scaled as well. Can be used on classes
+ * or methods.
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface TimeoutMillis {
+ long value();
+ }
+
+ /** Specify the display size for the GeckoSession in device pixels */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface WithDisplay {
+ int width();
+
+ int height();
+ }
+
+ /** Specify that the main session should not be opened at the start of the test. */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface ClosedSessionAtStart {
+ boolean value() default true;
+ }
+
+ /**
+ * Specify that the test will set a delegate to null when creating a session, rather than setting
+ * the delegate to a proxy. The test cannot wait on any delegates that are set to null.
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface NullDelegate {
+ Class<?> value();
+
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface List {
+ NullDelegate[] value();
+ }
+ }
+
+ /**
+ * Specify a list of GeckoSession settings to be applied to the GeckoSession object under test.
+ * Can be used on classes or methods. Note that the settings values must be string literals
+ * regardless of the type of the settings.
+ *
+ * <p>Enable tracking protection for a particular test:
+ *
+ * <pre>
+ * &#64;Setting.List(&#64;Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+ * value = "false"))
+ * &#64;Test public void test() { ... }
+ * </pre>
+ *
+ * <p>Use multiple settings:
+ *
+ * <pre>
+ * &#64;Setting.List({&#64;Setting(key = Setting.Key.USE_PRIVATE_MODE,
+ * value = "true"),
+ * &#64;Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+ * value = "false")})
+ * </pre>
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface Setting {
+ enum Key {
+ CHROME_URI,
+ DISPLAY_MODE,
+ ALLOW_JAVASCRIPT,
+ SCREEN_ID,
+ USE_PRIVATE_MODE,
+ USE_TRACKING_PROTECTION,
+ FULL_ACCESSIBILITY_TREE;
+
+ private final GeckoSessionSettings.Key<?> mKey;
+ private final Class<?> mType;
+
+ Key() {
+ final Field field;
+ try {
+ field = GeckoSessionSettings.class.getDeclaredField(name());
+ field.setAccessible(true);
+ mKey = (GeckoSessionSettings.Key<?>) field.get(null);
+ } catch (final NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+
+ final ParameterizedType genericType = (ParameterizedType) field.getGenericType();
+ mType = (Class<?>) genericType.getActualTypeArguments()[0];
+ }
+
+ @SuppressWarnings("unchecked")
+ public void set(final GeckoSessionSettings settings, final String value) {
+ try {
+ if (boolean.class.equals(mType) || Boolean.class.equals(mType)) {
+ final 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)) {
+ final 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)) {
+ final 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 (final 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(final 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 final Set<Class<?>> DEFAULT_DELEGATES = new HashSet<>();
+
+ static {
+ DEFAULT_DELEGATES.add(Autofill.Delegate.class);
+ DEFAULT_DELEGATES.add(ContentBlocking.Delegate.class);
+ DEFAULT_DELEGATES.add(ContentDelegate.class);
+ DEFAULT_DELEGATES.add(HistoryDelegate.class);
+ DEFAULT_DELEGATES.add(MediaDelegate.class);
+ DEFAULT_DELEGATES.add(MediaSession.Delegate.class);
+ DEFAULT_DELEGATES.add(NavigationDelegate.class);
+ DEFAULT_DELEGATES.add(PermissionDelegate.class);
+ DEFAULT_DELEGATES.add(PrintDelegate.class);
+ DEFAULT_DELEGATES.add(ProgressDelegate.class);
+ DEFAULT_DELEGATES.add(PromptDelegate.class);
+ DEFAULT_DELEGATES.add(ScrollDelegate.class);
+ DEFAULT_DELEGATES.add(SelectionActionDelegate.class);
+ DEFAULT_DELEGATES.add(TextInputDelegate.class);
+ }
+
+ private static final Set<Class<?>> DEFAULT_RUNTIME_DELEGATES = new HashSet<>();
+
+ static {
+ DEFAULT_RUNTIME_DELEGATES.add(Autocomplete.StorageDelegate.class);
+ DEFAULT_RUNTIME_DELEGATES.add(ActivityDelegate.class);
+ DEFAULT_RUNTIME_DELEGATES.add(GeckoRuntime.Delegate.class);
+ DEFAULT_RUNTIME_DELEGATES.add(OrientationController.OrientationDelegate.class);
+ DEFAULT_RUNTIME_DELEGATES.add(ServiceWorkerDelegate.class);
+ DEFAULT_RUNTIME_DELEGATES.add(WebNotificationDelegate.class);
+ DEFAULT_RUNTIME_DELEGATES.add(WebExtensionController.PromptDelegate.class);
+ DEFAULT_RUNTIME_DELEGATES.add(WebPushDelegate.class);
+ }
+
+ private static class DefaultImpl
+ implements
+ // Session delegates
+ Autofill.Delegate,
+ ContentBlocking.Delegate,
+ ContentDelegate,
+ HistoryDelegate,
+ MediaDelegate,
+ MediaSession.Delegate,
+ NavigationDelegate,
+ PermissionDelegate,
+ PrintDelegate,
+ ProgressDelegate,
+ PromptDelegate,
+ ScrollDelegate,
+ SelectionActionDelegate,
+ TextInputDelegate,
+ // Runtime delegates
+ ActivityDelegate,
+ Autocomplete.StorageDelegate,
+ GeckoRuntime.Delegate,
+ OrientationController.OrientationDelegate,
+ ServiceWorkerDelegate,
+ WebExtensionController.PromptDelegate,
+ WebNotificationDelegate,
+ WebPushDelegate {
+ @Override
+ public GeckoResult<Intent> onStartActivityForResult(@NonNull PendingIntent intent) {
+ return null;
+ }
+
+ // The default impl of this will call `onLocationChange(2)` which causes duplicated
+ // call records, to avoid that we implement it here so that it doesn't do anything.
+ @Override
+ public void onLocationChange(
+ @NonNull GeckoSession session,
+ @Nullable String url,
+ @NonNull List<ContentPermission> perms) {}
+
+ @Override
+ public void onShutdown() {}
+
+ @Override
+ public GeckoResult<GeckoSession> onOpenWindow(@NonNull String url) {
+ return GeckoResult.fromValue(null);
+ }
+ }
+
+ private static final DefaultImpl DEFAULT_IMPL = new DefaultImpl();
+
+ 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(final RuntimeTelemetry.Delegate delegate) {
+ RuntimeCreator.setTelemetryDelegate(delegate);
+ }
+
+ public @Nullable GeckoDisplay getDisplay() {
+ return mDisplays.get(mMainSession);
+ }
+
+ protected static void setDelegate(
+ final @NonNull Class<?> cls,
+ final @NonNull GeckoSession session,
+ final @Nullable Object delegate)
+ throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
+ if (cls == GeckoSession.TextInputDelegate.class) {
+ session.getTextInput().setDelegate((TextInputDelegate) delegate);
+ } else if (cls == ContentBlocking.Delegate.class) {
+ session.setContentBlockingDelegate((ContentBlocking.Delegate) delegate);
+ } else if (cls == Autofill.Delegate.class) {
+ session.setAutofillDelegate((Autofill.Delegate) delegate);
+ } else if (cls == MediaSession.Delegate.class) {
+ session.setMediaSessionDelegate((MediaSession.Delegate) delegate);
+ } else {
+ GeckoSession.class.getMethod("set" + cls.getSimpleName(), cls).invoke(session, delegate);
+ }
+ }
+
+ protected static void setRuntimeDelegate(
+ final @NonNull Class<?> cls,
+ final @NonNull GeckoRuntime runtime,
+ final @Nullable Object delegate) {
+ if (cls == Autocomplete.StorageDelegate.class) {
+ runtime.setAutocompleteStorageDelegate((Autocomplete.StorageDelegate) delegate);
+ } else if (cls == ActivityDelegate.class) {
+ runtime.setActivityDelegate((ActivityDelegate) delegate);
+ } else if (cls == GeckoRuntime.Delegate.class) {
+ runtime.setDelegate((GeckoRuntime.Delegate) delegate);
+ } else if (cls == OrientationController.OrientationDelegate.class) {
+ runtime
+ .getOrientationController()
+ .setDelegate((OrientationController.OrientationDelegate) delegate);
+ } else if (cls == ServiceWorkerDelegate.class) {
+ runtime.setServiceWorkerDelegate((ServiceWorkerDelegate) delegate);
+ } else if (cls == WebNotificationDelegate.class) {
+ runtime.setWebNotificationDelegate((WebNotificationDelegate) delegate);
+ } else if (cls == WebExtensionController.PromptDelegate.class) {
+ runtime
+ .getWebExtensionController()
+ .setPromptDelegate((WebExtensionController.PromptDelegate) delegate);
+ } else if (cls == WebPushDelegate.class) {
+ runtime.getWebPushController().setDelegate((WebPushDelegate) delegate);
+ } else {
+ throw new IllegalStateException("Unknown runtime delegate " + cls.getName());
+ }
+ }
+
+ protected static Object getRuntimeDelegate(
+ final @NonNull Class<?> cls, final @NonNull GeckoRuntime runtime) {
+ if (cls == Autocomplete.StorageDelegate.class) {
+ return runtime.getAutocompleteStorageDelegate();
+ } else if (cls == ActivityDelegate.class) {
+ return runtime.getActivityDelegate();
+ } else if (cls == GeckoRuntime.Delegate.class) {
+ return runtime.getDelegate();
+ } else if (cls == OrientationController.OrientationDelegate.class) {
+ return runtime.getOrientationController().getDelegate();
+ } else if (cls == ServiceWorkerDelegate.class) {
+ return runtime.getServiceWorkerDelegate();
+ } else if (cls == WebNotificationDelegate.class) {
+ return runtime.getWebNotificationDelegate();
+ } else if (cls == WebExtensionController.PromptDelegate.class) {
+ return runtime.getWebExtensionController().getPromptDelegate();
+ } else if (cls == WebPushDelegate.class) {
+ return runtime.getWebPushController().getDelegate();
+ } else {
+ throw new IllegalStateException("Unknown runtime delegate " + cls.getName());
+ }
+ }
+
+ 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();
+
+ final Set<Class<?>> set = new HashSet<>(DEFAULT_DELEGATES);
+ set.addAll(DEFAULT_RUNTIME_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) {
+ assertThat(
+ "Null-delegate must be valid interface class",
+ delegate,
+ either(isIn(DEFAULT_DELEGATES)).or(isIn(DEFAULT_RUNTIME_DELEGATES)));
+ mNullDelegates.add(delegate);
+ }
+
+ 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 isDefaultDelegate =
+ DEFAULT_DELEGATES.contains(method.getDeclaringClass());
+ final boolean isDefaultRuntimeDelegate =
+ DEFAULT_RUNTIME_DELEGATES.contains(method.getDeclaringClass());
+
+ if (!ignore) {
+ if (isDefaultDelegate) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ final GeckoSession session;
+ if (!isDefaultDelegate) {
+ 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 (!isDefaultDelegate && !isDefaultRuntimeDelegate) {
+ assertThat("External delegate should be registered", call, notNullValue());
+ }
+ }
+
+ Object returnValue = null;
+ try {
+ mCurrentMethodCall = call;
+ if (call != null && call.target != null) {
+ returnValue = method.invoke(call.target, args);
+ } else {
+ returnValue = method.invoke(DEFAULT_IMPL, args);
+ }
+ } catch (final IllegalAccessException | InvocationTargetException e) {
+ throw unwrapRuntimeException(e);
+ } finally {
+ mCurrentMethodCall = null;
+ }
+
+ return returnValue;
+ }
+ };
+
+ final Set<Class<?>> delegates = new HashSet<>();
+ delegates.addAll(DEFAULT_DELEGATES);
+ delegates.addAll(DEFAULT_RUNTIME_DELEGATES);
+ final Class<?>[] classes = delegates.toArray(new Class<?>[delegates.size()]);
+ mCallbackProxy = Proxy.newProxyInstance(GeckoSession.class.getClassLoader(), classes, recorder);
+ mAllDelegates = new HashSet<>(delegates);
+
+ mMainSession = new GeckoSession(settings);
+ prepareSession(mMainSession);
+ prepareRuntime(getRuntime());
+
+ 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 prepareRuntime(final GeckoRuntime runtime) {
+ UiThreadUtils.waitForCondition(
+ () -> RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL,
+ env.getDefaultTimeoutMillis());
+ for (final Class<?> cls : DEFAULT_RUNTIME_DELEGATES) {
+ setRuntimeDelegate(cls, runtime, mNullDelegates.contains(cls) ? null : mCallbackProxy);
+ }
+ }
+
+ 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 (final 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 cleanupRuntime(final GeckoRuntime runtime) {
+ for (final Class<?> cls : DEFAULT_RUNTIME_DELEGATES) {
+ setRuntimeDelegate(cls, runtime, null);
+ }
+ }
+
+ 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() {
+ final File dumpDir = new File(getProfilePath(), "minidumps");
+ for (final File dump : dumpDir.listFiles()) {
+ dump.delete();
+ }
+ }
+
+ protected void cleanupExtensions() throws Throwable {
+ final WebExtensionController controller = getRuntime().getWebExtensionController();
+ final List<WebExtension> list = waitForResult(controller.list(), env.getDefaultTimeoutMillis());
+
+ boolean hasTestSupport = false;
+ // Uninstall any left-over extensions
+ for (final WebExtension extension : list) {
+ if (!extension.id.equals(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) {
+ waitForResult(controller.uninstall(extension), env.getDefaultTimeoutMillis());
+ } 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);
+ }
+
+ cleanupRuntime(getRuntime());
+ 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);
+ }
+
+ // These markers are used by runjunit.py to capture the logcat of a test
+ private static final String TEST_START_MARKER = "test_start 1f0befec-3ff2-40ff-89cf-b127eb38b1ec";
+ private static final String TEST_END_MARKER = "test_end c5ee677f-bc83-49bd-9e28-2d35f3d0f059";
+
+ @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(mMessageDelegate);
+ getRuntime();
+
+ Log.e(LOGTAG, TEST_START_MARKER + " " + description);
+ 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");
+ } catch (final Throwable t) {
+ Log.e(LOGTAG, "Error", t);
+ exceptionRef.set(t);
+ } finally {
+ try {
+ mServer.stop();
+ cleanupStatement();
+ } catch (final Throwable t) {
+ exceptionRef.compareAndSet(null, t);
+ }
+ Log.e(LOGTAG, TEST_END_MARKER + " " + description);
+ }
+ });
+
+ final 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, null);
+ }
+
+ /**
+ * 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, new CallRequirement(true, -1, null)));
+ break;
+ }
+ }
+ isSessionCallback = true;
+ }
+
+ assertThat(
+ "Delegate should be a GeckoSession delegate " + "or registered external delegate",
+ isSessionCallback,
+ equalTo(true));
+
+ waitUntilCalled(session, callback, waitMethods, null);
+ }
+
+ /**
+ * 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);
+ methodCalls.add(new MethodCall(session, method, ac, /* target */ null));
+ }
+ isSessionCallback = true;
+ }
+
+ assertThat(
+ "Delegate should implement a GeckoSession, GeckoRuntime delegate "
+ + "or registered external delegate",
+ isSessionCallback,
+ equalTo(true));
+
+ waitUntilCalled(session, callback.getClass(), methodCalls, callback);
+ }
+
+ /**
+ * * Implement this interface in {@link #waitUntilCalled} to allow waiting until this method
+ * returns true. E.g. for when the test needs to wait for a specific value on a delegate call.
+ */
+ public interface ShouldContinue {
+ /**
+ * Whether the test should keep waiting or not.
+ *
+ * @return true if the test should keep waiting.
+ */
+ default boolean shouldContinue() {
+ return false;
+ }
+ }
+
+ private void waitUntilCalled(
+ final @Nullable GeckoSession session,
+ final @NonNull Class<?> delegate,
+ final @NonNull List<MethodCall> methodCalls,
+ final @Nullable Object callback) {
+ 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 sessionDelegate;
+ try {
+ sessionDelegate = 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",
+ sessionDelegate,
+ sameInstance(mCallbackProxy));
+ }
+
+ for (final Class<?> ifce : DEFAULT_RUNTIME_DELEGATES) {
+ final Object runtimeDelegate = getRuntimeDelegate(ifce, getRuntime());
+ 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",
+ runtimeDelegate,
+ 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;
+ final long startTime = SystemClock.uptimeMillis();
+
+ beforeWait();
+
+ ShouldContinue cont = new ShouldContinue() {};
+ if (callback instanceof ShouldContinue) {
+ cont = (ShouldContinue) callback;
+ }
+
+ List<MethodCall> pendingMethodCalls =
+ methodCalls.stream()
+ .filter(
+ mc -> mc.requirement != null && mc.requirement.count != 0 && mc.requirement.allowed)
+ .collect(Collectors.toList());
+
+ int order = 0;
+ while (!calledAny || !pendingMethodCalls.isEmpty() || cont.shouldContinue()) {
+ 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 CallRecord record = mCallRecords.get(index);
+ final MethodCall recorded = record.methodCall;
+
+ final boolean isDelegate = recorded.method.getDeclaringClass().isAssignableFrom(delegate);
+
+ calledAny |= isDelegate;
+ index++;
+
+ final int i = methodCalls.indexOf(recorded);
+ if (i < 0) {
+ continue;
+ }
+
+ final MethodCall methodCall = methodCalls.get(i);
+ assertAllowMoreCalls(methodCall);
+
+ methodCall.incrementCounter();
+ assertOrder(methodCall, order);
+ order = Math.max(methodCall.getOrder(), order);
+
+ if (methodCall.allowUnlimitedCalls() || !methodCall.allowMoreCalls()) {
+ pendingMethodCalls.remove(methodCall);
+ }
+
+ if (isDelegate && callback != null) {
+ try {
+ mCurrentMethodCall = methodCall;
+ record.method.invoke(callback, record.args);
+ } catch (IllegalAccessException | InvocationTargetException e) {
+ throw unwrapRuntimeException(e);
+ } finally {
+ mCurrentMethodCall = null;
+ }
+ }
+ }
+
+ 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);
+ }
+
+ /**
+ * Synthesize a mouse move 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 synthesizeMouseMove(final @NonNull GeckoSession session, final int x, final int y) {
+ final MotionEvent.PointerProperties pointerProperty = new MotionEvent.PointerProperties();
+ pointerProperty.id = 0;
+ pointerProperty.toolType = MotionEvent.TOOL_TYPE_MOUSE;
+
+ final MotionEvent.PointerCoords pointerCoord = new MotionEvent.PointerCoords();
+ pointerCoord.x = x;
+ pointerCoord.y = y;
+
+ final MotionEvent.PointerProperties[] pointerProperties =
+ new MotionEvent.PointerProperties[] {pointerProperty};
+ final MotionEvent.PointerCoords[] pointerCoords =
+ new MotionEvent.PointerCoords[] {pointerCoord};
+
+ final long moveTime = SystemClock.uptimeMillis();
+ final MotionEvent moveEvent =
+ MotionEvent.obtain(
+ moveTime,
+ SystemClock.uptimeMillis(),
+ MotionEvent.ACTION_HOVER_MOVE,
+ 1,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 1.0f,
+ 1.0f,
+ 0,
+ 0,
+ InputDevice.SOURCE_MOUSE,
+ 0);
+ session.getPanZoomController().onTouchEvent(moveEvent);
+ }
+
+ /**
+ * Simulates a press to the Home button, causing the application to go to onPause. NB: Some time
+ * must elapse for the event to fully occur.
+ *
+ * @param context starting the Home intent
+ */
+ public void simulatePressHome(Context context) {
+ Intent intent = new Intent();
+ intent.setAction(Intent.ACTION_MAIN);
+ intent.addCategory(Intent.CATEGORY_HOME);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Simulates returningGeckoViewTestActivity to the foreground. Activity must already be in use.
+ * NB: Some time must elapse for the event to fully occur.
+ *
+ * @param context starting the intent
+ */
+ public void requestActivityToForeground(Context context) {
+ Intent notificationIntent = new Intent(context, GeckoViewTestActivity.class);
+ notificationIntent.setAction(Intent.ACTION_MAIN);
+ notificationIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+ notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(notificationIntent);
+ }
+
+ /**
+ * Mock Location Provider can be used in testing for creating mock locations. NB: Likely also need
+ * to set test setting geo.provider.testing to false to prevent network geolocation from
+ * interfering when using.
+ */
+ public class MockLocationProvider {
+
+ private final LocationManager locationManager;
+ private final String mockProviderName;
+ private boolean isActiveTestProvider = false;
+ private double mockLatitude;
+ private double mockLongitude;
+ private float mockAccuracy = .000001f;
+ private boolean doContinuallyPost;
+
+ @Nullable private ScheduledExecutorService executor;
+
+ /**
+ * Mock Location Provider adds a test provider to the location manager and controls sending mock
+ * locations. Use @{@link #postLocation()} to post the location to the location manager.
+ * Use @{@link #removeMockLocationProvider()} to remove the location provider to clean-up the
+ * test harness. Default accuracy is .000001f.
+ *
+ * @param locationManager location manager to accept the locations
+ * @param mockProviderName location provider that will use this location
+ * @param mockLatitude initial latitude in degrees that @{@link #postLocation()} will use
+ * @param mockLongitude initial longitude in degrees that @{@link #postLocation()} will use
+ * @param doContinuallyPost when posting a location, continue to post every 3s to keep location
+ * current
+ */
+ public MockLocationProvider(
+ LocationManager locationManager,
+ String mockProviderName,
+ double mockLatitude,
+ double mockLongitude,
+ boolean doContinuallyPost) {
+ this.locationManager = locationManager;
+ this.mockProviderName = mockProviderName;
+ this.mockLatitude = mockLatitude;
+ this.mockLongitude = mockLongitude;
+ this.doContinuallyPost = doContinuallyPost;
+ addMockLocationProvider();
+ }
+
+ /** Adds a mock location provider that can have locations manually set. */
+ private void addMockLocationProvider() {
+ // Ensures that only one location provider with this name exists
+ removeMockLocationProvider();
+ locationManager.addTestProvider(
+ mockProviderName,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ Criteria.POWER_LOW,
+ Criteria.ACCURACY_FINE);
+ locationManager.setTestProviderEnabled(mockProviderName, true);
+ isActiveTestProvider = true;
+ }
+
+ /**
+ * Removes the location provider. Recommend calling when ending test to prevent the mock
+ * provider remaining as a test provider.
+ */
+ public void removeMockLocationProvider() {
+ stopPostingLocation();
+ try {
+ locationManager.removeTestProvider(mockProviderName);
+ } catch (Exception e) {
+ // Throws an exception if there is no provider with that name
+ }
+ isActiveTestProvider = false;
+ }
+
+ /**
+ * Sets the mock location on MockLocationProvider, that will be used by @{@link #postLocation()}
+ *
+ * @param latitude latitude in degrees to mock
+ * @param longitude longitude in degrees to mock
+ */
+ public void setMockLocation(double latitude, double longitude) {
+ mockLatitude = latitude;
+ mockLongitude = longitude;
+ }
+
+ /**
+ * Sets the mock location on a MockLocationProvider, that will be used by @{@link
+ * #postLocation()} . Note, changing the accuracy can affect the importance of the mock provider
+ * compared to other location providers.
+ *
+ * @param latitude latitude in degrees to mock
+ * @param longitude longitude in degrees to mock
+ * @param accuracy horizontal accuracy in meters to mock
+ */
+ public void setMockLocation(double latitude, double longitude, float accuracy) {
+ mockLatitude = latitude;
+ mockLongitude = longitude;
+ mockAccuracy = accuracy;
+ }
+
+ /**
+ * When doContinuallyPost is set to true, @{@link #postLocation()} will post the location to the
+ * location manager every 3s. When set to false, @{@link #postLocation()} will only post the
+ * location once. Purpose is to prevent the location from becoming stale.
+ *
+ * @param doContinuallyPost setting for continually posting the location after calling @{@link
+ * #postLocation()}
+ */
+ public void setDoContinuallyPost(boolean doContinuallyPost) {
+ this.doContinuallyPost = doContinuallyPost;
+ }
+
+ /**
+ * Shutsdown and removes the executor created by @{@link #postLocation()} when @{@link
+ * #doContinuallyPost is true} to stop posting the location.
+ */
+ public void stopPostingLocation() {
+ if (executor != null) {
+ executor.shutdown();
+ executor = null;
+ }
+ }
+
+ /**
+ * Posts the set location to the system location manager. If @{@link #doContinuallyPost} is
+ * true, the location will be posted every 3s by an executor, otherwise will post once.
+ */
+ public void postLocation() {
+ if (!isActiveTestProvider) {
+ throw new IllegalStateException("The mock test provider is not active.");
+ }
+
+ // Ensure the thread that was posting a location (if applicable) is stopped.
+ stopPostingLocation();
+
+ // Set Location
+ Location location = new Location(mockProviderName);
+ location.setAccuracy(mockAccuracy);
+ location.setLatitude(mockLatitude);
+ location.setLongitude(mockLongitude);
+ location.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos());
+ location.setTime(System.currentTimeMillis());
+ locationManager.setTestProviderLocation(mockProviderName, location);
+ Log.i(
+ LOGTAG,
+ mockProviderName
+ + " is posting location, lat: "
+ + mockLatitude
+ + " lon: "
+ + mockLongitude
+ + " acc: "
+ + mockAccuracy);
+ // Continually post location
+ if (doContinuallyPost) {
+ executor = Executors.newScheduledThreadPool(1);
+ executor.scheduleAtFixedRate(
+ new Runnable() {
+ @Override
+ public void run() {
+ location.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos());
+ location.setTime(System.currentTimeMillis());
+ locationManager.setTestProviderLocation(mockProviderName, location);
+ Log.i(
+ LOGTAG,
+ mockProviderName
+ + " is posting location, lat: "
+ + mockLatitude
+ + " lon: "
+ + mockLongitude
+ + " acc: "
+ + mockAccuracy);
+ }
+ },
+ 0,
+ 3,
+ TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ Map<GeckoSession, WebExtension.Port> mPorts = new HashMap<>();
+
+ private class MessageDelegate implements WebExtension.MessageDelegate, WebExtension.PortDelegate {
+ @Override
+ public void onConnect(final @NonNull WebExtension.Port port) {
+ // Sometimes we get a new onConnect call _before_ onDisconnect, so we might
+ // have to detach the port here before we attach to a new one
+ detach(mPorts.remove(port.sender.session));
+ attach(port);
+ }
+
+ private void attach(WebExtension.Port port) {
+ mPorts.put(port.sender.session, port);
+ port.setDelegate(mMessageDelegate);
+ }
+
+ private void detach(WebExtension.Port port) {
+ // If there are pending messages for this port we need to resolve them with an exception
+ // otherwise the test will wait for them indefinitely.
+ for (final String id : mPendingResponses.get(port)) {
+ final EvalJSResult result = new EvalJSResult();
+ result.exception = new PortDisconnectException();
+ mPendingMessages.put(id, result);
+ }
+ mPendingResponses.remove(port);
+ }
+
+ @Override
+ public void onPortMessage(
+ @NonNull final Object message, @NonNull final WebExtension.Port port) {
+ final JSONObject response = (JSONObject) message;
+
+ final String id;
+ try {
+ id = response.getString("id");
+ final 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 (final JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onDisconnect(final @NonNull WebExtension.Port port) {
+ detach(port);
+ // Sometimes the onDisconnect call comes _after_ the new onConnect so we need to check
+ // here whether this port is still in use.
+ if (mPorts.get(port.sender.session) == port) {
+ mPorts.remove(port.sender.session);
+ }
+ }
+
+ public class PortDisconnectException extends RuntimeException {
+ public PortDisconnectException() {
+ super(
+ "The port disconnected before a message could be received."
+ + "Usually this happens when the page navigates away while "
+ + "waiting for a message.");
+ }
+ }
+ }
+
+ private MessageDelegate mMessageDelegate = new MessageDelegate();
+
+ private static class EvalJSResult {
+ Object value;
+ Object exception;
+ }
+
+ Map<String, EvalJSResult> mPendingMessages = new HashMap<>();
+ MultiMap<WebExtension.Port, String> mPendingResponses = new MultiMap<>();
+
+ 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 (final JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+
+ final WebExtension.Port port = mPorts.get(session);
+ port.postMessage(message);
+
+ return waitForMessage(port, id);
+ }
+
+ public int getSessionPid(final @NonNull GeckoSession session) {
+ final Double dblPid = (Double) webExtensionApiCall(session, "GetPidForTab", null);
+ return dblPid.intValue();
+ }
+
+ public String getProfilePath() {
+ return (String) webExtensionApiCall("GetProfilePath", null);
+ }
+
+ public int[] getAllSessionPids() {
+ final JSONArray jsonPids = (JSONArray) webExtensionApiCall("GetAllBrowserPids", null);
+ final int[] pids = new int[jsonPids.length()];
+ for (int i = 0; i < jsonPids.length(); i++) {
+ try {
+ pids[i] = jsonPids.getInt(i);
+ } catch (final JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return pids;
+ }
+
+ public void killContentProcess(final int pid) {
+ webExtensionApiCall(
+ "KillContentProcess",
+ args -> {
+ args.put("pid", pid);
+ });
+ }
+
+ public boolean getActive(final @NonNull GeckoSession session) {
+ final Boolean isActive = (Boolean) webExtensionApiCall(session, "GetActive", null);
+ return isActive;
+ }
+
+ public void triggerCookieBannerDetected(final @NonNull GeckoSession session) {
+ webExtensionApiCall(session, "TriggerCookieBannerDetected", null);
+ }
+
+ public void triggerCookieBannerHandled(final @NonNull GeckoSession session) {
+ webExtensionApiCall(session, "TriggerCookieBannerHandled", null);
+ }
+
+ private Object waitForMessage(final WebExtension.Port port, final String id) {
+ mPendingResponses.add(port, id);
+ UiThreadUtils.waitForCondition(() -> mPendingMessages.containsKey(id), mTimeoutMillis);
+ mPendingResponses.remove(port);
+
+ 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 (final 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(final 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 selector.
+ *
+ * @param selector Selector that matches the link
+ * @return String representing the color, e.g. rgb(0, 0, 255)
+ */
+ public String getLinkColor(final GeckoSession session, final String selector) {
+ return (String)
+ webExtensionApiCall(
+ session,
+ "GetLinkColor",
+ args -> {
+ args.put("selector", selector);
+ });
+ }
+
+ public List<String> getRequestedLocales() {
+ try {
+ final JSONArray locales = (JSONArray) webExtensionApiCall("GetRequestedLocales", null);
+ final List<String> result = new ArrayList<>();
+
+ for (int i = 0; i < locales.length(); i++) {
+ result.add(locales.getString(i));
+ }
+
+ return result;
+ } catch (final 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 all SSL overrides */
+ public void removeAllCertOverrides() {
+ webExtensionApiCall("RemoveAllCertOverrides", null);
+ }
+
+ 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 GeckoSession session, final float resolution) {
+ webExtensionApiCall(
+ session,
+ "SetResolutionAndScaleTo",
+ args -> {
+ args.put("resolution", resolution);
+ });
+ }
+
+ /** Invokes nsIDOMWindowUtils.flushApzRepaints. */
+ public void flushApzRepaints(final GeckoSession session) {
+ webExtensionApiCall(session, "FlushApzRepaints", null);
+ }
+
+ /** Invokes a simplified version of promiseAllPaintsDone in paint_listener.js. */
+ public void promiseAllPaintsDone(final GeckoSession session) {
+ webExtensionApiCall(session, "PromiseAllPaintsDone", null);
+ }
+
+ /** Returns true if Gecko is using a GPU process. */
+ public boolean usingGpuProcess() {
+ return (Boolean) webExtensionApiCall("UsingGpuProcess", null);
+ }
+
+ /** Kills the GPU process cleanly with generating a crash report. */
+ public void killGpuProcess() {
+ webExtensionApiCall("KillGpuProcess", null);
+ }
+
+ /** Causes the GPU process to crash. */
+ public void crashGpuProcess() {
+ webExtensionApiCall("CrashGpuProcess", null);
+ }
+
+ /** Clears sites from the HSTS list. */
+ public void clearHSTSState() {
+ webExtensionApiCall("ClearHSTSState", 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 (final JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+
+ final WebExtension.Port port;
+ if (session == null) {
+ port = RuntimeCreator.backgroundPort();
+ } 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.
+ port = mPorts.get(session);
+ }
+
+ port.postMessage(message);
+ return waitForMessage(port, 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 final GeckoResult<T> result) throws Throwable {
+ return waitForResult(result, mTimeoutMillis);
+ }
+
+ /**
+ * This is similar to waitForResult with specific timeout.
+ *
+ * @param result A {@link GeckoResult} instance.
+ * @param timeout timeout in milliseconds
+ * @param <T> The type of the value held by the {@link GeckoResult}
+ * @return The value of the completed {@link GeckoResult}.
+ */
+ private <T> T waitForResult(@NonNull final GeckoResult<T> result, final long timeout)
+ throws Throwable {
+ beforeWait();
+ try {
+ return UiThreadUtils.waitForResult(result, timeout);
+ } 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..b496ae41fa
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java
@@ -0,0 +1,11 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+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/Environment.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java
new file mode 100644
index 0000000000..b2ed9df4d5
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.util;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Debug;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.mozilla.geckoview.BuildConfig;
+
+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() {
+ // NOTE: This isn't accurate, as it doesn't take into account the default
+ // value of the pref or environment variables like
+ // `MOZ_FORCE_DISABLE_FISSION`.
+ return getEnvVar("MOZ_FORCE_ENABLE_FISSION").equals("1");
+ }
+
+ public boolean isWebrender() {
+ return getEnvVar("MOZ_WEBRENDER").equals("1");
+ }
+
+ public boolean isIsolatedProcess() {
+ return BuildConfig.MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS;
+ }
+
+ 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();
+ }
+
+ public boolean isNightly() {
+ return BuildConfig.NIGHTLY_BUILD;
+ }
+}
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..5431719bc9
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.util;
+
+import static org.mozilla.geckoview.ContentBlocking.SafeBrowsingProvider;
+
+import android.os.Process;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.test.platform.app.InstrumentationRegistry;
+import java.util.concurrent.atomic.AtomicInteger;
+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;
+
+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 final RuntimeTelemetry.Histogram metric) {
+ if (delegate != null) {
+ delegate.onHistogram(metric);
+ }
+ }
+
+ @Override
+ public void onBooleanScalar(@NonNull final RuntimeTelemetry.Metric<Boolean> metric) {
+ if (delegate != null) {
+ delegate.onBooleanScalar(metric);
+ }
+ }
+
+ @Override
+ public void onStringScalar(@NonNull final RuntimeTelemetry.Metric<String> metric) {
+ if (delegate != null) {
+ delegate.onStringScalar(metric);
+ }
+ }
+
+ @Override
+ public void onLongScalar(@NonNull final 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 final WebExtension.Port port) {
+ sBackgroundPort = port;
+ port.setDelegate(sWrapperPortDelegate);
+ }
+ };
+
+ private static WebExtension.PortDelegate sWrapperPortDelegate =
+ new WebExtension.PortDelegate() {
+ @Override
+ public void onPortMessage(
+ @NonNull final Object message, @NonNull final 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(final RuntimeTelemetry.Delegate delegate) {
+ sRuntimeTelemetryProxy.delegate = delegate;
+ }
+
+ public static void setPortDelegate(final WebExtension.PortDelegate portDelegate) {
+ sPortDelegate = portDelegate;
+ }
+
+ @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(() -> Process.killProcess(Process.myPid()));
+
+ return sRuntime;
+ }
+}
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..b842a58c2f
--- /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.* // ktlint-disable no-wildcard-imports
+
+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.getBody())
+ }
+
+ 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..f5aee4db3b
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.util;
+
+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;
+import org.mozilla.geckoview.GeckoResult;
+
+public class UiThreadUtils {
+ private static Method sGetNextMessage = null;
+
+ static {
+ try {
+ sGetNextMessage = MessageQueue.class.getDeclaredMethod("next");
+ sGetNextMessage.setAccessible(true);
+ } catch (final 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 final GeckoResult<T> result, final 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(final 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) {
+ final 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(final 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);
+ }
+ }
+ }
+}