Bug 1463576 - 4. Add SelectionActionDelegateTest; r?snorp draft
authorJim Chen <nchen@mozilla.com>
Fri, 01 Jun 2018 13:39:21 -0400
changeset 802941 0e035e93f52e18d2be4539269dad4fa57620b4bf
parent 802940 ef46c0d2724f9f0053c9cbc44ef578bb943adeb4
child 802942 82753a2b34abd231a6096981cce1c119cf1dde9d
push id112001
push userbmo:nchen@mozilla.com
push dateFri, 01 Jun 2018 17:40:20 +0000
reviewerssnorp
bugs1463576
milestone62.0a1
Bug 1463576 - 4. Add SelectionActionDelegateTest; r?snorp Add SelectionActionDelegateTest, which includes tests for all built-in selection actions, for multiple content types such as input or contentEditable. MozReview-Commit-ID: Dzlzjo233Fe
mobile/android/geckoview/src/androidTest/assets/www/selectionAction.html
mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame.html
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/selectionAction.html
@@ -0,0 +1,19 @@
+<html>
+    <head><title>SelectionActionDelegate Test</title></head>
+    <body>
+        <div id="text">lorem</div>
+        <input id="input" value="ipsum">
+        <textarea id="textarea">dolor</textarea>
+        <div id="contenteditable" contenteditable="true">sit</div>
+        <iframe id="iframe" src="selectionAction_frame.html"></iframe>
+        <iframe id="designmode" src="selectionAction_frame.html"></iframe>
+    </body>
+    <script>
+        addEventListener("load", function() {
+            document.getElementById("iframe").contentDocument.body.textContent = "amet";
+            var designmode = document.getElementById("designmode");
+            designmode.contentDocument.body.textContent = "consectetur";
+            designmode.contentDocument.designMode = "on";
+        });
+    </script>
+</html>
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame.html
@@ -0,0 +1,3 @@
+<html>
+    <body></body>
+</html>
--- 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
@@ -20,24 +20,25 @@ import org.junit.rules.ErrorCollector
 import kotlin.reflect.KClass
 
 /**
  * Common base class for tests using GeckoSessionTestRule,
  * providing the test rule and other utilities.
  */
 open class BaseSessionTest(noErrorCollector: Boolean = false) {
     companion object {
-        const val INVALID_URI = "http://www.test.invalid/"
+        const val CLICK_TO_RELOAD_HTML_PATH = "/assets/www/clickToReload.html"
+        const val DOWNLOAD_HTML_PATH = "/assets/www/download.html"
         const val HELLO_HTML_PATH = "/assets/www/hello.html"
         const val HELLO2_HTML_PATH = "/assets/www/hello2.html"
+        const val INVALID_URI = "http://www.test.invalid/"
         const val NEW_SESSION_HTML_PATH = "/assets/www/newSession.html"
         const val NEW_SESSION_CHILD_HTML_PATH = "/assets/www/newSession_child.html"
-        const val CLICK_TO_RELOAD_HTML_PATH = "/assets/www/clickToReload.html"
+        const val SELECTION_ACTION_PATH = "/assets/www/selectionAction.html"
         const val TITLE_CHANGE_HTML_PATH = "/assets/www/titleChange.html"
-        const val DOWNLOAD_HTML_PATH = "/assets/www/download.html"
         const val TRACKERS_PATH = "/assets/www/trackers.html"
     }
 
     @get:Rule val sessionRule = GeckoSessionTestRule()
 
     @get:Rule val errors = ErrorCollector()
 
     val mainSession get() = sessionRule.session
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt
@@ -0,0 +1,407 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoResponse
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDevToolsAPI
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
+import org.mozilla.geckoview.test.util.Callbacks
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.support.test.InstrumentationRegistry
+import android.support.test.filters.MediumTest
+
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers.*
+import org.junit.Assume.assumeThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.junit.runners.Parameterized.Parameter
+import org.junit.runners.Parameterized.Parameters
+
+@MediumTest
+@RunWith(Parameterized::class)
+@WithDevToolsAPI
+@WithDisplay(width = 100, height = 100)
+class SelectionActionDelegateTest : BaseSessionTest() {
+    enum class ContentType {
+        DIV, EDITABLE_ELEMENT, IFRAME
+    }
+
+    companion object {
+        @get:Parameters(name = "{0}")
+        @JvmStatic
+        val parameters: List<Array<out Any>> = listOf(
+                arrayOf("#text", ContentType.DIV, "lorem", false),
+                arrayOf("#input", ContentType.EDITABLE_ELEMENT, "ipsum", true),
+                arrayOf("#textarea", ContentType.EDITABLE_ELEMENT, "dolor", true),
+                arrayOf("#contenteditable", ContentType.DIV, "sit", true),
+                arrayOf("#iframe", ContentType.IFRAME, "amet", false),
+                arrayOf("#designmode", ContentType.IFRAME, "consectetur", true))
+    }
+
+    @field:Parameter(0) @JvmField var id: String = ""
+    @field:Parameter(1) @JvmField var type: ContentType = ContentType.DIV
+    @field:Parameter(2) @JvmField var initialContent: String = ""
+    @field:Parameter(3) @JvmField var editable: Boolean = false
+
+    private val selectedContent by lazy {
+        when (type) {
+            ContentType.DIV -> SelectedDiv(id, initialContent)
+            ContentType.EDITABLE_ELEMENT -> SelectedEditableElement(id, initialContent)
+            ContentType.IFRAME -> SelectedFrame(id, initialContent)
+        }
+    }
+
+    private val collapsedContent by lazy {
+        when (type) {
+            ContentType.DIV -> CollapsedDiv(id)
+            ContentType.EDITABLE_ELEMENT -> CollapsedEditableElement(id)
+            ContentType.IFRAME -> CollapsedFrame(id)
+        }
+    }
+
+
+    /** Generic tests for each content type. */
+
+    @Test fun request() {
+        if (editable) {
+            withClipboard ("text") {
+                testThat(selectedContent, {}, hasShowActionRequest(
+                        FLAG_IS_EDITABLE, arrayOf(ACTION_COLLAPSE_TO_START, ACTION_COLLAPSE_TO_END,
+                                                  ACTION_COPY, ACTION_CUT, ACTION_DELETE,
+                                                  ACTION_PASTE, ACTION_SELECT_ALL)))
+            }
+        } else {
+            testThat(selectedContent, {}, hasShowActionRequest(
+                    0, arrayOf(ACTION_COPY, 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_PASTE, ACTION_SELECT_ALL)))
+        }
+    }
+
+    @Test fun request_noClipboard() = assumingEditable(true) {
+        withClipboard("") {
+            testThat(collapsedContent, {}, hasShowActionRequest(
+                    FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED, arrayOf(ACTION_SELECT_ALL)))
+        }
+    }
+
+    @Test fun cut() = assumingEditable(true) {
+        withClipboard("") {
+            testThat(selectedContent, withResponse(ACTION_CUT), copiesText(), deletesContent())
+        }
+    }
+
+    @Test fun copy() = withClipboard("") {
+        testThat(selectedContent, withResponse(ACTION_COPY), copiesText())
+    }
+
+    @Test fun paste() = assumingEditable(true) {
+        withClipboard("pasted") {
+            testThat(selectedContent, withResponse(ACTION_PASTE), changesContentTo("pasted"))
+        }
+    }
+
+    @Test fun delete() = assumingEditable(true) {
+        testThat(selectedContent, withResponse(ACTION_DELETE), deletesContent())
+    }
+
+    @Test fun selectAll() {
+        if (type == ContentType.DIV && !editable) {
+            // "Select all" for non-editable div means selecting the whole document.
+            testThat(selectedContent, withResponse(ACTION_SELECT_ALL), changesSelectionTo(
+                    both(containsString(selectedContent.initialContent))
+                            .and(not(equalTo(selectedContent.initialContent)))))
+        } else {
+            testThat(if (editable) collapsedContent else selectedContent,
+                     withResponse(ACTION_SELECT_ALL),
+                     changesSelectionTo(selectedContent.initialContent))
+        }
+    }
+
+    @Test fun unselect() = assumingEditable(false) {
+        testThat(selectedContent, withResponse(ACTION_UNSELECT), clearsSelection())
+    }
+
+    @Test fun multipleActions() = assumingEditable(false) {
+        withClipboard("") {
+            testThat(selectedContent, withResponse(ACTION_COPY, ACTION_UNSELECT),
+                     copiesText(), clearsSelection())
+        }
+    }
+
+    @Test fun collapseToStart() = assumingEditable(true) {
+        testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_START), hasSelectionAt(0))
+    }
+
+    @Test fun collapseToEnd() = assumingEditable(true) {
+        testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_END),
+                 hasSelectionAt(selectedContent.initialContent.length))
+    }
+
+
+    /** 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: (GeckoResponse<String>) -> Unit,
+                         result: (SelectedContent) -> Unit,
+                         vararg sideEffects: (SelectedContent) -> Unit) {
+
+        sessionRule.setPrefsUntilTestEnd(mapOf(
+                "layout.accessiblecaret.enabled_on_touch" to true))
+
+        mainSession.loadTestPath(SELECTION_ACTION_PATH)
+        mainSession.waitForPageStop()
+
+        content.focus()
+
+        // Show selection actions for collapsed selections, so we can test them.
+        // Also, always show accessible carets / selection actions for changes due to JS calls.
+        sessionRule.setPrefsUntilTestEnd(mapOf(
+                "geckoview.selection_action.show_on_focus" to true,
+                "layout.accessiblecaret.script_change_update_mode" to 2))
+
+        mainSession.delegateDuringNextWait(object : Callbacks.SelectionActionDelegate {
+            override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection, actions: Array<out String>, response: GeckoResponse<String>) {
+                respondingWith(response)
+            }
+        })
+
+        content.select()
+        mainSession.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
+            @AssertCalled(count = 1)
+            override fun onShowActionRequest(session: GeckoSession, selection: Selection, actions: Array<out String>, response: GeckoResponse<String>) {
+                assertThat("Initial content should match",
+                           selection.text, equalTo(content.initialContent))
+            }
+        })
+
+        result(content)
+        sideEffects.forEach { it(content) }
+    }
+
+
+    /** Helpers. */
+
+    private val clipboard by lazy {
+        InstrumentationRegistry.getTargetContext().getSystemService(Context.CLIPBOARD_SERVICE)
+                as ClipboardManager
+    }
+
+    private fun withClipboard(content: String = "", lambda: () -> Unit) {
+        val oldClip = clipboard.primaryClip
+        try {
+            clipboard.primaryClip = ClipData.newPlainText("", content)
+
+            sessionRule.addExternalDelegateUntilTestEnd(
+                    ClipboardManager.OnPrimaryClipChangedListener::class,
+                    clipboard::addPrimaryClipChangedListener,
+                    clipboard::removePrimaryClipChangedListener,
+                    ClipboardManager.OnPrimaryClipChangedListener {})
+            lambda()
+        } finally {
+            clipboard.primaryClip = 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(
+                $('$id').firstChild, 0, $('$id').firstChild, $to)""")
+        }
+
+        override fun select() = selectTo(initialContent.length)
+
+        override val content: String get() {
+            return mainSession.evaluateJS("$('$id').textContent") as String
+        }
+
+        override val selectionOffsets: Pair<Int, Int> get() {
+            if (mainSession.evaluateJS("""
+                document.getSelection().anchorNode !== $('$id').firstChild ||
+                document.getSelection().focusNode !== $('$id').firstChild""") as Boolean) {
+                return Pair(-1, -1)
+            }
+            val offsets = mainSession.evaluateJS("""[
+                document.getSelection().anchorOffset,
+                document.getSelection().focusOffset]""").asJSList<Double>()
+            return Pair(offsets[0].toInt(), offsets[1].toInt())
+        }
+    }
+
+    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("$('$id').focus()")
+        }
+
+        override fun select() {
+            mainSession.evaluateJS("$('$id').select()")
+        }
+
+        override val content: String get() {
+            return mainSession.evaluateJS("$('$id').value") as String
+        }
+
+        override val selectionOffsets: Pair<Int, Int> get() {
+            val offsets = mainSession.evaluateJS(
+                    "[ $('$id').selectionStart, $('$id').selectionEnd ]").asJSList<Double>()
+            return Pair(offsets[0].toInt(), offsets[1].toInt())
+        }
+    }
+
+    inner class CollapsedEditableElement(id: String) : SelectedEditableElement(id, "") {
+        override fun select() {
+            mainSession.evaluateJS("$('$id').setSelectionRange(0, 0)")
+        }
+    }
+
+    open inner class SelectedFrame(val id: String,
+                                   override val initialContent: String) : SelectedContent {
+        override fun focus() {
+            mainSession.evaluateJS("$('$id').contentWindow.focus()")
+        }
+
+        protected fun selectTo(to: Int) {
+            mainSession.evaluateJS("""(function() {
+                    var doc = $('$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("$('$id').contentDocument.body.textContent") as String
+        }
+
+        override val selectionOffsets: Pair<Int, Int> get() {
+            val offsets = mainSession.evaluateJS("""(function() {
+                    var sel = $('$id').contentDocument.getSelection();
+                    var text = $('$id').contentDocument.body.firstChild;
+                    if (sel.anchorNode !== text || sel.focusNode !== text) {
+                        return [-1, -1];
+                    }
+                    return [sel.anchorOffset, sel.focusOffset];
+                })()""").asJSList<Double>()
+            return Pair(offsets[0].toInt(), offsets[1].toInt())
+        }
+    }
+
+    inner class CollapsedFrame(id: String) : SelectedFrame(id, "") {
+        override fun select() = selectTo(0)
+    }
+
+
+    /** Lambda for responding with certain actions. */
+
+    private fun withResponse(vararg actions: String): (GeckoResponse<String>) -> Unit {
+        var responded = false
+        return { response ->
+            if (!responded) {
+                responded = true
+                actions.forEach { response.respond(it) }
+            }
+        }
+    }
+
+
+    /** Lambdas for asserting the results of actions. */
+
+    private fun hasShowActionRequest(expectedFlags: Int,
+                                     expectedActions: Array<out String>) = { it: SelectedContent ->
+        mainSession.forCallbacksDuringWait(object : Callbacks.SelectionActionDelegate {
+            @AssertCalled(count = 1)
+            override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection, actions: Array<out String>, response: GeckoResponse<String>) {
+                assertThat("Selection text should be valid",
+                           selection.text, equalTo(it.initialContent))
+                assertThat("Selection flags should be valid",
+                           selection.flags, equalTo(expectedFlags))
+                assertThat("Selection rect should be valid",
+                           selection.clientRect.isEmpty, equalTo(false))
+                assertThat("Actions must be valid", actions,
+                           arrayContainingInAnyOrder(*expectedActions))
+            }
+        })
+    }
+
+    private fun copiesText() = { it: SelectedContent ->
+        sessionRule.waitUntilCalled(ClipboardManager.OnPrimaryClipChangedListener {
+            assertThat("Clipboard should contain correct text",
+                       clipboard.primaryClip?.getItemAt(0)?.text,
+                       hasToString(it.initialContent))
+        })
+    }
+
+    private fun changesSelectionTo(text: String) = changesSelectionTo(equalTo(text))
+
+    private fun changesSelectionTo(matcher: Matcher<String>) = { _: SelectedContent ->
+        sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
+            @AssertCalled(count = 1)
+            override fun onShowActionRequest(session: GeckoSession, selection: Selection, actions: Array<out String>, response: GeckoResponse<String>) {
+                assertThat("New selection text should match", selection.text, matcher)
+            }
+        })
+    }
+
+    private fun clearsSelection() = { _: SelectedContent ->
+        sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
+            @AssertCalled(count = 1)
+            override fun onHideAction(session: GeckoSession, reason: Int) {
+                assertThat("Hide reason should be correct",
+                           reason, equalTo(HIDE_REASON_NO_SELECTION))
+            }
+        })
+    }
+
+    private fun hasSelectionAt(offset: Int) = hasSelectionAt(offset, offset)
+
+    private fun hasSelectionAt(start: Int, end: Int) = { it: SelectedContent ->
+        assertThat("Selection offsets should match",
+                   it.selectionOffsets, equalTo(Pair(start, end)))
+    }
+
+    private fun deletesContent() = changesContentTo("")
+
+    private fun changesContentTo(content: String) = { it: SelectedContent ->
+        assertThat("Changed content should match", it.content, equalTo(content))
+    }
+}