<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