summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt')
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt913
1 files changed, 913 insertions, 0 deletions
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))
+ }
+}