<lambda>null1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
2  * Any copyright is dedicated to the Public Domain.
3    http://creativecommons.org/publicdomain/zero/1.0/ */
4 
5 package org.mozilla.geckoview.test
6 
7 import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.*
8 import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
9 import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
10 import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
11 import org.mozilla.geckoview.test.util.Callbacks
12 
13 import android.content.ClipData
14 import android.content.ClipboardManager
15 import android.content.Context
16 import android.graphics.RectF;
17 import androidx.test.platform.app.InstrumentationRegistry
18 import androidx.test.filters.MediumTest
19 
20 import org.hamcrest.Matcher
21 import org.hamcrest.Matchers.*
22 import org.json.JSONArray
23 import org.junit.Assume.assumeThat
24 import org.junit.Test
25 import org.junit.runner.RunWith
26 import org.junit.runners.Parameterized
27 import org.junit.runners.Parameterized.Parameter
28 import org.junit.runners.Parameterized.Parameters
29 import org.mozilla.geckoview.GeckoSession
30 
31 @MediumTest
32 @RunWith(Parameterized::class)
33 @WithDisplay(width = 100, height = 100)
34 class SelectionActionDelegateTest : BaseSessionTest() {
35     enum class ContentType {
36         DIV, EDITABLE_ELEMENT, IFRAME
37     }
38 
39     companion object {
40         @get:Parameters(name = "{0}")
41         @JvmStatic
42         val parameters: List<Array<out Any>> = listOf(
43                 arrayOf("#text", ContentType.DIV, "lorem", false),
44                 arrayOf("#input", ContentType.EDITABLE_ELEMENT, "ipsum", true),
45                 arrayOf("#textarea", ContentType.EDITABLE_ELEMENT, "dolor", true),
46                 arrayOf("#contenteditable", ContentType.DIV, "sit", true),
47                 arrayOf("#iframe", ContentType.IFRAME, "amet", false),
48                 arrayOf("#designmode", ContentType.IFRAME, "consectetur", true))
49     }
50 
51     @field:Parameter(0) @JvmField var id: String = ""
52     @field:Parameter(1) @JvmField var type: ContentType = ContentType.DIV
53     @field:Parameter(2) @JvmField var initialContent: String = ""
54     @field:Parameter(3) @JvmField var editable: Boolean = false
55 
<lambda>null56     private val selectedContent by lazy {
57         when (type) {
58             ContentType.DIV -> SelectedDiv(id, initialContent)
59             ContentType.EDITABLE_ELEMENT -> SelectedEditableElement(id, initialContent)
60             ContentType.IFRAME -> SelectedFrame(id, initialContent)
61         }
62     }
63 
<lambda>null64     private val collapsedContent by lazy {
65         when (type) {
66             ContentType.DIV -> CollapsedDiv(id)
67             ContentType.EDITABLE_ELEMENT -> CollapsedEditableElement(id)
68             ContentType.IFRAME -> CollapsedFrame(id)
69         }
70     }
71 
72 
73     /** Generic tests for each content type. */
74 
requestnull75     @Test fun request() {
76         if (editable) {
77             withClipboard ("text") {
78                 testThat(selectedContent, {}, hasShowActionRequest(
79                         FLAG_IS_EDITABLE, arrayOf(ACTION_COLLAPSE_TO_START, ACTION_COLLAPSE_TO_END,
80                                                   ACTION_COPY, ACTION_CUT, ACTION_DELETE,
81                                                   ACTION_HIDE, ACTION_PASTE)))
82             }
83         } else {
84             testThat(selectedContent, {}, hasShowActionRequest(
85                     0, arrayOf(ACTION_COPY, ACTION_HIDE, ACTION_SELECT_ALL,
86                                            ACTION_UNSELECT)))
87         }
88     }
89 
<lambda>null90     @Test fun request_collapsed() = assumingEditable(true) {
91         withClipboard ("text") {
92             testThat(collapsedContent, {}, hasShowActionRequest(
93                     FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED,
94                     arrayOf(ACTION_HIDE, ACTION_PASTE, ACTION_SELECT_ALL)))
95         }
96     }
97 
<lambda>null98     @Test fun request_noClipboard() = assumingEditable(true) {
99         withClipboard("") {
100             testThat(collapsedContent, {}, hasShowActionRequest(
101                     FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED,
102                     arrayOf(ACTION_HIDE, ACTION_SELECT_ALL)))
103         }
104     }
105 
hidenull106     @Test fun hide() = testThat(selectedContent, withResponse(ACTION_HIDE), clearsSelection())
107 
108     @Test fun cut() = assumingEditable(true) {
109         withClipboard("") {
110             testThat(selectedContent, withResponse(ACTION_CUT), copiesText(), deletesContent())
111         }
112     }
113 
<lambda>null114     @Test fun copy() = withClipboard("") {
115         testThat(selectedContent, withResponse(ACTION_COPY), copiesText())
116     }
117 
<lambda>null118     @Test fun paste() = assumingEditable(true) {
119         withClipboard("pasted") {
120             testThat(selectedContent, withResponse(ACTION_PASTE), changesContentTo("pasted"))
121         }
122     }
123 
<lambda>null124     @Test fun delete() = assumingEditable(true) {
125         testThat(selectedContent, withResponse(ACTION_DELETE), deletesContent())
126     }
127 
selectAllnull128     @Test fun selectAll() {
129         if (type == ContentType.DIV && !editable) {
130             // "Select all" for non-editable div means selecting the whole document.
131             testThat(selectedContent, withResponse(ACTION_SELECT_ALL), changesSelectionTo(
132                     both(containsString(selectedContent.initialContent))
133                             .and(not(equalTo(selectedContent.initialContent)))))
134         } else {
135             testThat(if (editable) collapsedContent else selectedContent,
136                      withResponse(ACTION_SELECT_ALL),
137                      changesSelectionTo(selectedContent.initialContent))
138         }
139     }
140 
<lambda>null141     @Test fun unselect() = assumingEditable(false) {
142         testThat(selectedContent, withResponse(ACTION_UNSELECT), clearsSelection())
143     }
144 
<lambda>null145     @Test fun multipleActions() = assumingEditable(false) {
146         withClipboard("") {
147             testThat(selectedContent, withResponse(ACTION_COPY, ACTION_UNSELECT),
148                      copiesText(), clearsSelection())
149         }
150     }
151 
<lambda>null152     @Test fun collapseToStart() = assumingEditable(true) {
153         testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_START), hasSelectionAt(0))
154     }
155 
<lambda>null156     @Test fun collapseToEnd() = assumingEditable(true) {
157         testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_END),
158                  hasSelectionAt(selectedContent.initialContent.length))
159     }
160 
pagehidenull161     @Test fun pagehide() {
162         // Navigating to another page should hide selection action.
163         testThat(selectedContent, { mainSession.loadTestPath(HELLO_HTML_PATH) }, clearsSelection())
164     }
165 
deactivatenull166     @Test fun deactivate() {
167         // Blurring the window should hide selection action.
168         testThat(selectedContent, { mainSession.setFocused(false) }, clearsSelection())
169         mainSession.setFocused(true)
170     }
171 
172     @NullDelegate(GeckoSession.SelectionActionDelegate::class)
clearDelegatenull173     @Test fun clearDelegate() {
174         var counter = 0
175         mainSession.selectionActionDelegate = object : Callbacks.SelectionActionDelegate {
176             override fun onHideAction(session: GeckoSession, reason: Int) {
177                 counter++
178             }
179         }
180 
181         mainSession.selectionActionDelegate = null
182         assertThat("Hide action should be called when clearing delegate",
183                    counter, equalTo(1))
184     }
185 
compareClientRectnull186     @Test fun compareClientRect() {
187         val jsCssReset = """(function() {
188             document.querySelector('${id}').style.display = "block";
189             document.querySelector('${id}').style.border = "0";
190             document.querySelector('${id}').style.padding = "0";
191         })()"""
192         val jsBorder10pxPadding10px = """(function() {
193             document.querySelector('${id}').style.display = "block";
194             document.querySelector('${id}').style.border = "10px solid";
195             document.querySelector('${id}').style.padding = "10px";
196         })()"""
197         val expectedDiff = RectF(20f, 20f, 20f, 20f) // left, top, right, bottom
198         testClientRect(selectedContent, jsCssReset, jsBorder10pxPadding10px, expectedDiff)
199     }
200 
201     /** Interface that defines behavior for a particular type of content */
202     private interface SelectedContent {
focusnull203         fun focus() {}
selectnull204         fun select() {}
205         val initialContent: String
206         val content: String
207         val selectionOffsets: Pair<Int, Int>
208     }
209 
210     /** Main method that performs test logic. */
testThatnull211     private fun testThat(content: SelectedContent,
212                          respondingWith: (Selection) -> Unit,
213                          result: (SelectedContent) -> Unit,
214                          vararg sideEffects: (SelectedContent) -> Unit) {
215 
216         mainSession.loadTestPath(INPUTS_PATH)
217         mainSession.waitForPageStop()
218 
219         content.focus()
220 
221         // Show selection actions for collapsed selections, so we can test them.
222         // Also, always show accessible carets / selection actions for changes due to JS calls.
223         sessionRule.setPrefsUntilTestEnd(mapOf(
224                 "geckoview.selection_action.show_on_focus" to true,
225                 "layout.accessiblecaret.script_change_update_mode" to 2))
226 
227         mainSession.delegateDuringNextWait(object : Callbacks.SelectionActionDelegate {
228             override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection) {
229                 respondingWith(selection)
230             }
231         })
232 
233         content.select()
234         mainSession.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
235             @AssertCalled(count = 1)
236             override fun onShowActionRequest(session: GeckoSession, selection: Selection) {
237                 assertThat("Initial content should match",
238                            selection.text, equalTo(content.initialContent))
239             }
240         })
241 
242         result(content)
243         sideEffects.forEach { it(content) }
244     }
245 
testClientRectnull246     private fun testClientRect(content: SelectedContent,
247                                initialJsA: String,
248                                initialJsB: String,
249                                expectedDiff: RectF) {
250 
251         // Show selection actions for collapsed selections, so we can test them.
252         // Also, always show accessible carets / selection actions for changes due to JS calls.
253         sessionRule.setPrefsUntilTestEnd(mapOf(
254                 "geckoview.selection_action.show_on_focus" to true,
255                 "layout.accessiblecaret.script_change_update_mode" to 2))
256 
257         mainSession.loadTestPath(INPUTS_PATH)
258         mainSession.waitForPageStop()
259 
260         val requestClientRect: (String) -> RectF = {
261             mainSession.reload()
262             mainSession.waitForPageStop()
263 
264             mainSession.evaluateJS(it)
265             content.focus()
266 
267             var clientRect = RectF()
268             content.select()
269             mainSession.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
270                 @AssertCalled(count = 1)
271                 override fun onShowActionRequest(session: GeckoSession, selection: Selection) {
272                     clientRect = selection.clientRect!!
273                 }
274             })
275 
276             clientRect
277         }
278 
279         val clientRectA = requestClientRect(initialJsA)
280         val clientRectB = requestClientRect(initialJsB)
281 
282         val fuzzyEqual = { a: Float, b: Float, e: Float -> Math.abs(a + e - b) <= 1 }
283         val result = fuzzyEqual(clientRectA.top, clientRectB.top, expectedDiff.top)
284                   && fuzzyEqual(clientRectA.left, clientRectB.left, expectedDiff.left)
285                   && fuzzyEqual(clientRectA.width(), clientRectB.width(), expectedDiff.width())
286                   && fuzzyEqual(clientRectA.height(), clientRectB.height(), expectedDiff.height())
287 
288         assertThat("Selection rect is not at expected location. a$clientRectA b$clientRectB",
289                    result, equalTo(true))
290     }
291 
292 
293     /** Helpers. */
294 
<lambda>null295     private val clipboard by lazy {
296         InstrumentationRegistry.getInstrumentation().targetContext.getSystemService(Context.CLIPBOARD_SERVICE)
297                 as ClipboardManager
298     }
299 
withClipboardnull300     private fun withClipboard(content: String = "", lambda: () -> Unit) {
301         val oldClip = clipboard.primaryClip
302         try {
303             clipboard.setPrimaryClip(ClipData.newPlainText("", content))
304 
305             sessionRule.addExternalDelegateUntilTestEnd(
306                     ClipboardManager.OnPrimaryClipChangedListener::class,
307                     clipboard::addPrimaryClipChangedListener,
308                     clipboard::removePrimaryClipChangedListener,
309                     ClipboardManager.OnPrimaryClipChangedListener {})
310             lambda()
311         } finally {
312             clipboard.setPrimaryClip(oldClip ?: ClipData.newPlainText("", ""))
313         }
314     }
315 
assumingEditablenull316     private fun assumingEditable(editable: Boolean, lambda: (() -> Unit)? = null) {
317         assumeThat("Assuming is ${if (editable) "" else "not "}editable",
318                    this.editable, equalTo(editable))
319         lambda?.invoke()
320     }
321 
322 
323     /** Behavior objects for different content types */
324 
325     open inner class SelectedDiv(val id: String,
326                                  override val initialContent: String) : SelectedContent {
selectTonull327         protected fun selectTo(to: Int) {
328             mainSession.evaluateJS("""document.getSelection().setBaseAndExtent(
329                 document.querySelector('$id').firstChild, 0,
330                 document.querySelector('$id').firstChild, $to)""")
331         }
332 
selectnull333         override fun select() = selectTo(initialContent.length)
334 
335         override val content: String get() {
336             return mainSession.evaluateJS("document.querySelector('$id').textContent") as String
337         }
338 
339         override val selectionOffsets: Pair<Int, Int> get() {
340             if (mainSession.evaluateJS("""
341                 document.getSelection().anchorNode !== document.querySelector('$id').firstChild ||
342                 document.getSelection().focusNode !== document.querySelector('$id').firstChild""") as Boolean) {
343                 return Pair(-1, -1)
344             }
345             val offsets = mainSession.evaluateJS("""[
346                 document.getSelection().anchorOffset,
347                 document.getSelection().focusOffset]""") as JSONArray
348             return Pair(offsets[0] as Int, offsets[1] as Int)
349         }
350     }
351 
352     inner class CollapsedDiv(id: String) : SelectedDiv(id, "") {
selectnull353         override fun select() = selectTo(0)
354     }
355 
356     open inner class SelectedEditableElement(
357             val id: String, override val initialContent: String) : SelectedContent {
358         override fun focus() {
359             mainSession.waitForJS("document.querySelector('$id').focus()")
360         }
361 
362         override fun select() {
363             mainSession.evaluateJS("document.querySelector('$id').select()")
364         }
365 
366         override val content: String get() {
367             return mainSession.evaluateJS("document.querySelector('$id').value") as String
368         }
369 
370         override val selectionOffsets: Pair<Int, Int> get() {
371             val offsets = mainSession.evaluateJS(
372                     """[ document.querySelector('$id').selectionStart,
373                         |document.querySelector('$id').selectionEnd ]""".trimMargin()) as JSONArray
374             return Pair(offsets[0] as Int, offsets[1] as Int)
375         }
376     }
377 
378     inner class CollapsedEditableElement(id: String) : SelectedEditableElement(id, "") {
selectnull379         override fun select() {
380             mainSession.evaluateJS("document.querySelector('$id').setSelectionRange(0, 0)")
381         }
382     }
383 
384     open inner class SelectedFrame(val id: String,
385                                    override val initialContent: String) : SelectedContent {
focusnull386         override fun focus() {
387             mainSession.evaluateJS("document.querySelector('$id').contentWindow.focus()")
388         }
389 
selectTonull390         protected fun selectTo(to: Int) {
391             mainSession.evaluateJS("""(function() {
392                     var doc = document.querySelector('$id').contentDocument;
393                     var text = doc.body.firstChild;
394                     doc.getSelection().setBaseAndExtent(text, 0, text, $to);
395                 })()""")
396         }
397 
selectnull398         override fun select() = selectTo(initialContent.length)
399 
400         override val content: String get() {
401             return mainSession.evaluateJS("document.querySelector('$id').contentDocument.body.textContent") as String
402         }
403 
404         override val selectionOffsets: Pair<Int, Int> get() {
405             val offsets = mainSession.evaluateJS("""(function() {
406                     var sel = document.querySelector('$id').contentDocument.getSelection();
407                     var text = document.querySelector('$id').contentDocument.body.firstChild;
408                     if (sel.anchorNode !== text || sel.focusNode !== text) {
409                         return [-1, -1];
410                     }
411                     return [sel.anchorOffset, sel.focusOffset];
412                 })()""") as JSONArray
413             return Pair(offsets[0] as Int, offsets[1] as Int)
414         }
415     }
416 
417     inner class CollapsedFrame(id: String) : SelectedFrame(id, "") {
selectnull418         override fun select() = selectTo(0)
419     }
420 
421 
422     /** Lambda for responding with certain actions. */
423 
424     private fun withResponse(vararg actions: String): (Selection) -> Unit {
425         var responded = false
426         return { response ->
427             if (!responded) {
428                 responded = true
429                 actions.forEach { response.execute(it) }
430             }
431         }
432     }
433 
434 
435     /** Lambdas for asserting the results of actions. */
436 
hasShowActionRequestnull437     private fun hasShowActionRequest(expectedFlags: Int,
438                                      expectedActions: Array<out String>) = { it: SelectedContent ->
439         mainSession.forCallbacksDuringWait(object : Callbacks.SelectionActionDelegate {
440             @AssertCalled(count = 1)
441             override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection) {
442                 assertThat("Selection text should be valid",
443                            selection.text, equalTo(it.initialContent))
444                 assertThat("Selection flags should be valid",
445                            selection.flags, equalTo(expectedFlags))
446                 assertThat("Selection rect should be valid",
447                            selection.clientRect!!.isEmpty, equalTo(false))
448                 assertThat("Actions must be valid", selection.availableActions.toTypedArray(),
449                            arrayContainingInAnyOrder(*expectedActions))
450             }
451         })
452     }
453 
copiesTextnull454     private fun copiesText() = { it: SelectedContent ->
455         sessionRule.waitUntilCalled(ClipboardManager.OnPrimaryClipChangedListener {
456             assertThat("Clipboard should contain correct text",
457                        clipboard.primaryClip?.getItemAt(0)?.text,
458                        hasToString(it.initialContent))
459         })
460     }
461 
changesSelectionTonull462     private fun changesSelectionTo(text: String) = changesSelectionTo(equalTo(text))
463 
464     private fun changesSelectionTo(matcher: Matcher<String>) = { _: SelectedContent ->
465         sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
466             @AssertCalled(count = 1)
467             override fun onShowActionRequest(session: GeckoSession, selection: Selection) {
468                 assertThat("New selection text should match", selection.text, matcher)
469             }
470         })
471     }
472 
clearsSelectionnull473     private fun clearsSelection() = { _: SelectedContent ->
474         sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
475             @AssertCalled(count = 1)
476             override fun onHideAction(session: GeckoSession, reason: Int) {
477                 assertThat("Hide reason should be correct",
478                            reason, equalTo(HIDE_REASON_NO_SELECTION))
479             }
480         })
481     }
482 
hasSelectionAtnull483     private fun hasSelectionAt(offset: Int) = hasSelectionAt(offset, offset)
484 
485     private fun hasSelectionAt(start: Int, end: Int) = { it: SelectedContent ->
486         assertThat("Selection offsets should match",
487                    it.selectionOffsets, equalTo(Pair(start, end)))
488     }
489 
deletesContentnull490     private fun deletesContent() = changesContentTo("")
491 
492     private fun changesContentTo(content: String) = { it: SelectedContent ->
493         assertThat("Changed content should match", it.content, equalTo(content))
494     }
495 }
496