Source code

Revision control

Copy as Markdown

Other Tools

/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
* Any copyright is dedicated to the Public Domain.
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.net.Uri
import android.os.Build
import android.util.Base64
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 pasteImage() {
assumeThat("Unnecessary to run multiple times", id, equalTo("#contenteditable"))
val bytes = this.getTestBytes("/assets/www/images/test.gif")
val base64Utf8String = Base64.encodeToString(bytes, Base64.NO_WRAP)
val result = "<img src=\"data:image/gif;base64,${base64Utf8String}\" alt=\"\">"
withImageClipboard("/assets/www/images/test.gif", "image/gif") {
testThat(selectedContent, withResponse(ACTION_PASTE), changesHtmlContentTo(result))
}
}
@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))
withClipboard("clipboardReadAllow") {} // Reset clipboard data
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(
"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))
withClipboard("clipboardReadDeny") {} // Reset clipboard data
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(
"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))
withClipboard("clipboardReadDeactivate") {} // Reset clipboard data
val url = createTestUrl(CLIPBOARD_READ_HTML_PATH)
mainSession.loadUri(url)
mainSession.waitForPageStop()
val result = GeckoResult<Void>()
val permissionResult = GeckoResult<AllowOrDeny>()
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 permissionResult
}
})
mainSession.synthesizeTap(50, 50) // Provides user activation.
sessionRule.waitForResult(result)
mainSession.delegateDuringNextWait(object : SelectionActionDelegate {
@AssertCalled
override fun onDismissClipboardPermissionRequest(session: GeckoSession) {
permissionResult.complete(AllowOrDeny.DENY)
}
})
mainSession.loadTestPath(HELLO_HTML_PATH)
sessionRule.waitForResult(permissionResult)
sessionRule.waitForPageStop()
}
@WithDisplay(width = 100, height = 100)
@Test
fun clipboardReadDismiss() {
assumeThat("Unnecessary to run multiple times", id, equalTo("#text"))
sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true))
withClipboard("clipboardReadDismiss") {} // Reset clipboard data
val url = createTestUrl(CLIPBOARD_READ_HTML_PATH)
mainSession.loadUri(url)
mainSession.waitForPageStop()
val result = GeckoResult<Void>()
val permissionResult = GeckoResult<AllowOrDeny>()
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 permissionResult
}
})
mainSession.synthesizeTap(50, 50) // Provides user activation.
sessionRule.waitForResult(result)
mainSession.delegateDuringNextWait(object : SelectionActionDelegate {
@AssertCalled
override fun onDismissClipboardPermissionRequest(session: GeckoSession) {
permissionResult.complete(AllowOrDeny.DENY)
}
})
mainSession.synthesizeTap(10, 10) // click to dismiss.
sessionRule.waitForResult(permissionResult)
}
/** Interface that defines behavior for a particular type of content */
private interface SelectedContent {
fun focus() {}
fun select() {}
val initialContent: String
val content: String
val htmlContent: 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()
sessionRule.waitForContentTransformsReceived(mainSession)
val requestClientRect: (String) -> RectF = {
mainSession.reload()
mainSession.waitForPageStop()
sessionRule.waitForContentTransformsReceived(mainSession)
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 expectedDiff$expectedDiff",
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 withImageClipboard(contentPath: String = "", mime: String = "", lambda: () -> Unit) {
val oldClip = clipboard.primaryClip
try {
TestContentProvider.setTestData(this.getTestBytes(contentPath), mime)
val clipData = ClipData("image", arrayOf(mime), ClipData.Item(Uri.parse("content://org.mozilla.geckoview.test.provider/gif")))
clipboard.setPrimaryClip(clipData)
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 htmlContent: String get() {
return mainSession.evaluateJS("document.querySelector('$id').innerHTML") 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 htmlContent: String get() {
return content
}
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 htmlContent: String get() {
return content
}
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 htmlContent: String get() {
return content
}
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))
}
private fun changesHtmlContentTo(content: String) = { it: SelectedContent ->
assertThat("Changed HTML content should match", it.htmlContent, equalTo(content))
}
}