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