diff options
Diffstat (limited to 'mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt')
-rw-r--r-- | mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt | 2275 |
1 files changed, 2275 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt new file mode 100644 index 0000000000..0f1fa260cb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt @@ -0,0 +1,2275 @@ +/* -*- 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.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.text.InputType +import android.util.SparseLongArray +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeProvider +import android.view.accessibility.AccessibilityRecord +import android.widget.EditText +import android.widget.FrameLayout +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ShouldContinue +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +const val DISPLAY_WIDTH = 480 +const val DISPLAY_HEIGHT = 640 + +@RunWith(AndroidJUnit4::class) +@MediumTest +@WithDisplay(width = DISPLAY_WIDTH, height = DISPLAY_HEIGHT) +class AccessibilityTest : BaseSessionTest() { + lateinit var view: View + val screenRect = Rect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT) + val provider: AccessibilityNodeProvider get() = view.accessibilityNodeProvider + private val nodeInfos = mutableListOf<AccessibilityNodeInfo>() + private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + // Given a child ID, return the virtual descendent ID. + private fun getVirtualDescendantId(childId: Long): Int { + try { + val getVirtualDescendantIdMethod = + AccessibilityNodeInfo::class.java.getMethod("getVirtualDescendantId", Long::class.java) + val virtualDescendantId = getVirtualDescendantIdMethod.invoke(null, childId) as Int + return if (virtualDescendantId == Int.MAX_VALUE) -1 else virtualDescendantId + } catch (ex: Exception) { + return 0 + } + } + + // Retrieve the virtual descendent ID of the event's source. + private fun getSourceId(event: AccessibilityEvent): Int { + try { + val getSourceIdMethod = + AccessibilityRecord::class.java.getMethod("getSourceNodeId") + return getVirtualDescendantId(getSourceIdMethod.invoke(event) as Long) + } catch (ex: Exception) { + return 0 + } + } + + private fun createNodeInfo(id: Int): AccessibilityNodeInfo { + val node = provider.createAccessibilityNodeInfo(id) + nodeInfos.add(node!!) + return node + } + + // Get a child ID by index. + private fun AccessibilityNodeInfo.getChildId(index: Int): Int { + try { + val field = AccessibilityNodeInfo::class.java.getDeclaredField("mChildNodeIds") + field.setAccessible(true) + val id = Class.forName("android.util.LongArray").getMethod("get", Int::class.java).invoke(field.get(this), index) as Long + return getVirtualDescendantId(id) + } catch (ex: Exception) { + return getVirtualDescendantId( + if (Build.VERSION.SDK_INT >= 21) { + AccessibilityNodeInfo::class.java.getMethod( + "getChildId", + Int::class.java, + ).invoke(this, index) as Long + } else { + ( + AccessibilityNodeInfo::class.java.getMethod("getChildNodeIds") + .invoke(this) as SparseLongArray + ).get(index) + }, + ) + } + } + + private interface EventDelegate { + fun onAccessibilityFocused(event: AccessibilityEvent) { } + fun onAccessibilityFocusCleared(event: AccessibilityEvent) { } + fun onClicked(event: AccessibilityEvent) { } + fun onFocused(event: AccessibilityEvent) { } + fun onSelected(event: AccessibilityEvent) { } + fun onScrolled(event: AccessibilityEvent) { } + fun onTextSelectionChanged(event: AccessibilityEvent) { } + fun onTextChanged(event: AccessibilityEvent) { } + fun onTextTraversal(event: AccessibilityEvent) { } + fun onWinContentChanged(event: AccessibilityEvent) { } + fun onWinStateChanged(event: AccessibilityEvent) { } + fun onAnnouncement(event: AccessibilityEvent) { } + } + + @Before fun setup() { + // We initialize a view with a parent and grandparent so that the + // accessibility events propagate up at least to the parent. + val context = InstrumentationRegistry.getInstrumentation().targetContext + view = FrameLayout(context) + FrameLayout(context).addView(view) + FrameLayout(context).addView(view.parent as View) + + // Force on accessibility and assign the session's accessibility + // object a view. + sessionRule.runtime.settings.forceEnableAccessibility = true + mainSession.accessibility.view = view + + // Set up an external delegate that will intercept accessibility events. + sessionRule.addExternalDelegateUntilTestEnd( + EventDelegate::class, + { newDelegate -> + (view.parent as View).setAccessibilityDelegate(object : View.AccessibilityDelegate() { + override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean { + when (event.eventType) { + AccessibilityEvent.TYPE_VIEW_FOCUSED -> newDelegate.onFocused(event) + AccessibilityEvent.TYPE_VIEW_CLICKED -> newDelegate.onClicked(event) + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED -> newDelegate.onAccessibilityFocused(event) + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED -> newDelegate.onAccessibilityFocusCleared(event) + AccessibilityEvent.TYPE_VIEW_SELECTED -> newDelegate.onSelected(event) + AccessibilityEvent.TYPE_VIEW_SCROLLED -> newDelegate.onScrolled(event) + AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED -> newDelegate.onTextSelectionChanged(event) + AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> newDelegate.onTextChanged(event) + AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY -> newDelegate.onTextTraversal(event) + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> newDelegate.onWinContentChanged(event) + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> newDelegate.onWinStateChanged(event) + AccessibilityEvent.TYPE_ANNOUNCEMENT -> newDelegate.onAnnouncement(event) + else -> {} + } + return false + } + }) + }, + { (view.parent as View).setAccessibilityDelegate(null) }, + object : EventDelegate { }, + ) + } + + @After fun teardown() { + sessionRule.runtime.settings.forceEnableAccessibility = false + mainSession.accessibility.view = null + if (Build.VERSION.SDK_INT < 33) { + nodeInfos.forEach { node -> + @Suppress("DEPRECATION") + node.recycle() + } + } + } + + private fun waitForInitialFocus(moveToFirstChild: Boolean = false) { + sessionRule.waitUntilCalled(object : GeckoSession.NavigationDelegate { + override fun onLoadRequest( + session: GeckoSession, + request: GeckoSession.NavigationDelegate.LoadRequest, + ): GeckoResult<AllowOrDeny>? { + return GeckoResult.allow() + } + }) + // XXX: Sometimes we get the window state change of the initial + // about:blank page loading. Need to figure out how to ignore that. + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { } + + @AssertCalled + override fun onWinStateChanged(event: AccessibilityEvent) { } + + @AssertCalled + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + if (moveToFirstChild) { + provider.performAction( + View.NO_ID, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + } + } + + @Test fun testRootNode() { + assertThat("provider is not null", provider, notNullValue()) + val node = createNodeInfo(AccessibilityNodeProvider.HOST_VIEW_ID) + assertThat( + "Root node should have WebView class name", + node.className.toString(), + equalTo("android.webkit.WebView"), + ) + } + + @Test fun testPageLoad() { + mainSession.loadTestPath(INPUTS_PATH) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { } + }) + } + + @Test fun testAccessibilityFocusAboutMozilla() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadUri("about:license") + + sessionRule.waitUntilCalled(object : GeckoSession.NavigationDelegate { + override fun onLoadRequest( + session: GeckoSession, + request: GeckoSession.NavigationDelegate.LoadRequest, + ): GeckoResult<AllowOrDeny>? { + return GeckoResult.allow() + } + }) + + // XXX: Local pages do not dispatch focus events when loaded + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onWinStateChanged(event: AccessibilityEvent) { } + + @AssertCalled + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + provider.performAction( + View.NO_ID, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Header is a11y focused", + node.contentDescription.toString(), + equalTo("Licenses"), + ) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Next text leaf is focused", + node.text.toString(), + equalTo("All of the "), + ) + } + }) + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + equalTo("free"), + ) + } + }) + } + + @Test fun testAccessibilityFocus() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(INPUTS_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Label accessibility focused", + node.className.toString(), + equalTo("android.view.View"), + ) + assertThat("Text node should not be focusable", node.isFocusable, equalTo(false)) + assertThat("Text node should be a11y focused", node.isAccessibilityFocused, equalTo(true)) + assertThat("Text node should not be clickable", node.isClickable, equalTo(false)) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Editbox accessibility focused", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + assertThat("Entry node should be focusable", node.isFocusable, equalTo(true)) + assertThat("Entry node should be a11y focused", node.isAccessibilityFocused, equalTo(true)) + assertThat("Entry node should be clickable", node.isClickable, equalTo(true)) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocusCleared(event: AccessibilityEvent) { + assertThat("Accessibility focused node is now cleared", getSourceId(event), equalTo(nodeId)) + val node = createNodeInfo(nodeId) + assertThat("Entry node should node be a11y focused", node.isAccessibilityFocused, equalTo(false)) + } + }) + } + + fun loadTestPage(page: String) { + mainSession.loadTestPath("/assets/www/accessibility/$page.html") + } + + @Test fun testTextEntryNode() { + loadTestPage("test-text-entry-node") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').focus()") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + val nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Focused EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Hint has field name", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("Name description"), + ) + } + } + }) + + mainSession.evaluateJS("document.querySelector('input[aria-label=Last]').focus()") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + val nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Focused EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Hint has field name", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("Last, required"), + ) + } + } + }) + } + + @Test fun testMoveCaretAccessibilityFocus() { + loadTestPage("test-move-caret-accessibility-focus") + waitForInitialFocus(false) + + mainSession.evaluateJS( + """ + this.select = function select(node, start, end) { + let r = new Range(); + r.setStart(node, start); + r.setEnd(node, end); + let s = getSelection(); + s.removeAllRanges(); + s.addRange(r); + }; + this.select(document.querySelector('p').childNodes[2], 2, 6); + """.trimIndent(), + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.text as String, equalTo(", sweet ")) + } + }) + + mainSession.evaluateJS( + """ + this.select(document.querySelector('p').lastElementChild.firstChild, 1, 2); + """.trimIndent(), + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.text as String, equalTo("world")) + } + }) + + // This focuses the link. + mainSession.finder.find("sweet", 0) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.contentDescription as String, equalTo("sweet")) + } + }) + + // reset caret position + mainSession.evaluateJS( + """ + this.select(document.body, 0, 0); + // Changing DOM selection doesn't focus the document! Force focus + // here so we can use that to determine when this is done. + document.activeElement.blur(); + """.trimIndent(), + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) {} + }) + + mainSession.finder.find("Hell", 0) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.text as String, equalTo("Hello ")) + } + }) + } + + private fun waitUntilTextSelectionChanged(fromIndex: Int, toIndex: Int, text: String) { + var eventFromIndex = -1 + var eventToIndex = -1 + var eventText = "" + do { + sessionRule.waitUntilCalled(object : EventDelegate { + override fun onTextSelectionChanged(event: AccessibilityEvent) { + eventFromIndex = event.fromIndex + eventToIndex = event.toIndex + eventText = event.text[0].toString() + } + }) + } while (fromIndex != eventFromIndex || toIndex != eventToIndex) + assertThat("text selection event text matches", eventText, equalTo(text)) + } + + private fun waitUntilTextTraversed( + fromIndex: Int, + toIndex: Int, + expectedNode: Int? = null, + ): Int { + var nodeId: Int = AccessibilityNodeProvider.HOST_VIEW_ID + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onTextTraversal(event: AccessibilityEvent) { + nodeId = getSourceId(event) + if (expectedNode != null) { + assertThat("Node matches", nodeId, equalTo(expectedNode)) + } + assertThat("fromIndex matches", event.fromIndex, equalTo(fromIndex)) + assertThat("toIndex matches", event.toIndex, equalTo(toIndex)) + } + }) + return nodeId + } + + private fun waitUntilClick(checked: Boolean) { + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onClicked(event: AccessibilityEvent) { + var nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Event's checked state matches", event.isChecked, equalTo(checked)) + assertThat("Checkbox node has correct checked state", node.isChecked, equalTo(checked)) + } + }) + } + + private fun waitUntilSelect(selected: Boolean) { + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onSelected(event: AccessibilityEvent) { + var nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Selectable node has correct selected state", node.isSelected, equalTo(selected)) + } + }) + } + + private fun setSelectionArguments(start: Int, end: Int): Bundle { + val arguments = Bundle(2) + arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, start) + arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, end) + return arguments + } + + private fun moveByGranularityArguments(granularity: Int, extendSelection: Boolean = false): Bundle { + val arguments = Bundle(2) + arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity) + arguments.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, extendSelection) + return arguments + } + + @Test fun testClipboard() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Writing clipboard requires foreground on Android 10. + activityRule.scenario?.onActivity { activity -> + activity.onWindowFocusChanged(true) + } + } + + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-clipboard") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('input').focus()") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Focused EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + } + + @AssertCalled(count = 1) + override fun onTextSelectionChanged(event: AccessibilityEvent) { + assertThat("fromIndex should be at start", event.fromIndex, equalTo(0)) + assertThat("toIndex should be at start", event.toIndex, equalTo(0)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(5, 11)) + waitUntilTextSelectionChanged(5, 11, "hello cruel world") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_COPY, null) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(11, 11)) + waitUntilTextSelectionChanged(11, 11, "hello cruel world") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PASTE, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onTextChanged(event: AccessibilityEvent) { + assertThat("text should be pasted", event.text[0].toString(), equalTo("hello cruel cruel world")) + assertThat("fromIndex is correct", event.fromIndex, equalTo(12)) + assertThat("addedCount is correct", event.addedCount, equalTo(6)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(17, 23)) + waitUntilTextSelectionChanged(17, 23, "hello cruel cruel world") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PASTE, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onTextChanged(event: AccessibilityEvent) { + assertThat("text should be pasted", event.text[0].toString(), equalTo("hello cruel cruel cruel")) + assertThat("fromIndex is correct", event.fromIndex, equalTo(18)) + assertThat("removedCount is correct", event.removedCount, equalTo(5)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(0, 0)) + waitUntilTextSelectionChanged(0, 0, "hello cruel cruel cruel") + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD, true), + ) + waitUntilTextSelectionChanged(0, 5, "hello cruel cruel cruel") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CUT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onTextChanged(event: AccessibilityEvent) { + assertThat("text should be cut", event.text[0].toString(), equalTo(" cruel cruel cruel")) + assertThat("fromIndex is correct", event.fromIndex, equalTo(0)) + assertThat("removedCount is correct", event.removedCount, equalTo(5)) + } + }) + } + + @Test fun testMoveByCharacter() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + waitUntilTextTraversed(0, 1, nodeId) // "L" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + waitUntilTextTraversed(1, 2, nodeId) // "o" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + waitUntilTextTraversed(0, 1, nodeId) // "L" + } + + @Test fun testMoveByWord() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + waitUntilTextTraversed(0, 5, nodeId) // "Lorem" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + waitUntilTextTraversed(6, 11, nodeId) // "ipsum" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + waitUntilTextTraversed(0, 5, nodeId) // "Lorem" + } + + @Test fun testMoveByLine() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE), + ) + waitUntilTextTraversed(0, 18, nodeId) // "Lorem ipsum dolor " + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE), + ) + waitUntilTextTraversed(18, 28, nodeId) // "sit amet, " + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE), + ) + waitUntilTextTraversed(0, 18, nodeId) // "Lorem ipsum dolor " + } + + @Test fun testMoveByCharacterAtEdges() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus() + + // Move to the first link containing "anim id". + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on link", node.contentDescription as String, startsWith("anim id")) + } + }) + + var success: Boolean + // Navigate forward through "anim id" character by character. + for (start in 0..6) { + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should succeed", success, equalTo(true)) + waitUntilTextTraversed(start, start + 1, nodeId) + } + + // Try to navigate forward past end. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should fail at end", success, equalTo(false)) + + // We're already on "d". Navigate backward through "anim i". + for (start in 5 downTo 0) { + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Prev char should succeed", success, equalTo(true)) + waitUntilTextTraversed(start, start + 1, nodeId) + } + + // Try to navigate backward past start. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Prev char should fail at start", success, equalTo(false)) + } + + @Test fun testMoveByWordAtEdges() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus() + + // Move to the first link containing "anim id". + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on link", node.contentDescription as String, startsWith("anim id")) + } + }) + + var success: Boolean + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should succeed", success, equalTo(true)) + waitUntilTextTraversed(0, 4, nodeId) // "anim" + + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should succeed", success, equalTo(true)) + waitUntilTextTraversed(5, 7, nodeId) // "id" + + // Try to navigate forward past end. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should fail at end", success, equalTo(false)) + + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Prev word should succeed", success, equalTo(true)) + waitUntilTextTraversed(0, 4, nodeId) // "anim" + + // Try to navigate backward past start. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Prev word should fail at start", success, equalTo(false)) + } + + @Test fun testMoveAtEndOfTextTrailingWhitespace() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + // Initial move backward to move to last word. + var success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Prev word should succeed", success, equalTo(true)) + waitUntilTextTraversed(418, 424, nodeId) // "mollit" + + // Try to move forward past last word. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should fail at last word", success, equalTo(false)) + + // Move forward by character (onto trailing space). + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should succeed", success, equalTo(true)) + waitUntilTextTraversed(424, 425, nodeId) // " " + + // Try to move forward past last character. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should fail at last char", success, equalTo(false)) + } + + @Test fun testHeadings() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-headings") + waitForInitialFocus() + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "HEADING") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first heading", node.contentDescription as String, startsWith("Fried cheese")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "First heading is level 1", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("heading level 1"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on second heading", node.contentDescription as String, startsWith("Popcorn shrimp")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Second heading is level 2", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("heading level 2"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on second heading", node.contentDescription as String, startsWith("Chicken fingers")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Third heading is level 3", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("heading level 3"), + ) + } + } + }) + } + + @Test fun testCheckbox() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-checkbox") + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Checkbox node is checkable", node.isCheckable, equalTo(true)) + assertThat("Checkbox node is clickable", node.isClickable, equalTo(true)) + assertThat("Checkbox node is focusable", node.isFocusable, equalTo(true)) + assertThat("Checkbox node is not checked", node.isChecked, equalTo(false)) + assertThat("Checkbox node has correct role", node.text.toString(), equalTo("many option")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Hint has description", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("description"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilClick(true) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilClick(false) + } + + @Test fun testExpandable() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-expandable") + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + if (Build.VERSION.SDK_INT >= 21) { + val node = createNodeInfo(nodeId) + assertThat("button is expandable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)) + assertThat("button is not collapsable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE))) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_EXPAND, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onClicked(event: AccessibilityEvent) { + assertThat("Clicked event is from same node", getSourceId(event), equalTo(nodeId)) + if (Build.VERSION.SDK_INT >= 21) { + val node = createNodeInfo(nodeId) + assertThat("button is collapsable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE)) + assertThat("button is not expandable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND))) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_COLLAPSE, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onClicked(event: AccessibilityEvent) { + assertThat("Clicked event is from same node", getSourceId(event), equalTo(nodeId)) + if (Build.VERSION.SDK_INT >= 21) { + val node = createNodeInfo(nodeId) + assertThat("button is expandable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)) + assertThat("button is not collapsable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE))) + } + } + }) + } + + @Test fun testSelectable() { + var nodeId = View.NO_ID + loadTestPage("test-selectable") + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Selectable node is clickable", node.isClickable, equalTo(true)) + assertThat("Selectable node is not selected", node.isSelected, equalTo(false)) + assertThat("Selectable node has correct text", node.text.toString(), equalTo("1")) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilSelect(true) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilSelect(false) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SELECT, null) + waitUntilSelect(true) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SELECT, null) + waitUntilSelect(false) + + // Ensure that querying an option outside of a selectable container + // doesn't crash (bug 1801879). + mainSession.evaluateJS("document.getElementById('outsideSelectable').focus()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Focused outsideSelectable", node.text.toString(), equalTo("outside selectable")) + } + }) + } + + @Test fun testMutation() { + loadTestPage("test-mutation") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 1 child", rootNode.childCount, equalTo(1)) + + assertThat( + "Section has 1 child", + createNodeInfo(rootNode.getChildId(0)).childCount, + equalTo(1), + ) + mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'none';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 0) + override fun onAnnouncement(event: AccessibilityEvent) { } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + assertThat( + "Section has no children", + createNodeInfo(rootNode.getChildId(0)).childCount, + equalTo(0), + ) + } + + @Test fun testLiveRegion() { + loadTestPage("test-live-region") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('#to_change').textContent = 'Hello';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("Hello")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + } + + @Test fun testLiveRegionDescendant() { + loadTestPage("test-live-region-descendant") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'none';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 0) + override fun onAnnouncement(event: AccessibilityEvent) { } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'block';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("I will be shown")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + } + + @Test fun testLiveRegionAtomic() { + loadTestPage("test-live-region-atomic") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('p').textContent = '4pm';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("The time is 4pm")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + mainSession.evaluateJS( + "document.querySelector('#container').removeAttribute('aria-atomic');" + + "document.querySelector('p').textContent = '5pm';", + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("5pm")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + } + + @Test fun testLiveRegionImage() { + loadTestPage("test-live-region-image") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('img').alt = 'sad';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("This picture is sad")) + } + }) + } + + @Test fun testLiveRegionImageLabeledBy() { + loadTestPage("test-live-region-image-labeled-by") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('img').setAttribute('aria-labelledby', 'l2');") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("Goodbye")) + } + }) + } + + private fun screenContainsNode(nodeId: Int): Boolean { + var node = createNodeInfo(nodeId) + var nodeBounds = Rect() + node.getBoundsInScreen(nodeBounds) + return screenRect.contains(nodeBounds) + } + + @Ignore // Bug 1506276 - We need to reliably wait for APZC here, and it's not trivial. + @Test + fun testScroll() { + var nodeId = View.NO_ID + loadTestPage("test-scroll.html") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onWinStateChanged(event: AccessibilityEvent) { } + + @AssertCalled(count = 1) + @Suppress("deprecation") + override fun onFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + var nodeBounds = Rect() + node.getBoundsInParent(nodeBounds) + assertThat("Default root node bounds are correct", nodeBounds, equalTo(screenRect)) + } + }) + + provider.performAction(View.NO_ID, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled for focused node to be onscreen", event.scrollY, greaterThan(0)) + assertThat("View is not scrolled to the end", event.scrollY, lessThan(event.maxScrollY)) + } + + @AssertCalled(count = 1, order = [3]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + }) + + SystemClock.sleep(100) + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled to the end", event.scrollY.toDouble(), closeTo(event.maxScrollY.toDouble(), 1.0)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is still onscreen", screenContainsNode(nodeId), equalTo(true)) + } + }) + + SystemClock.sleep(100) + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled to the beginning", event.scrollY, equalTo(0)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is offscreen", screenContainsNode(nodeId), equalTo(false)) + } + }) + + SystemClock.sleep(100) + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled to the end", event.scrollY.toDouble(), closeTo(event.maxScrollY.toDouble(), 1.0)) + } + + @AssertCalled(count = 1, order = [3]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + }) + } + + @Test + fun autoFill() { + // Wait for the accessibility nodes to populate. + mainSession.loadTestPath(FORMS_HTML_PATH) + waitForInitialFocus() + + val autoFills = mapOf( + "#user1" to "bar", + "#pass1" to "baz", + "#user2" to "bar", + "#pass2" to "baz", + ) + + if (Build.VERSION.SDK_INT >= 19) { + mapOf( + "#email1" to "a@b.c", + "#number1" to "24", + "#tel1" to "42", + ) + } else { + mapOf( + "#email1" to "bar", + "#number1" to "", + "#tel1" to "bar", + ) + } + + // Set up promises to monitor the values changing. + val promises = autoFills.flatMap { entry -> + // Repeat each test with both the top document and the iframe document. + arrayOf("document", "document.querySelector('#iframe').contentDocument").map { doc -> + mainSession.evaluatePromiseJS( + """new Promise(resolve => + $doc.querySelector('${entry.key}').addEventListener( + 'input', event => { + let eventInterface = + event instanceof $doc.defaultView.InputEvent ? "InputEvent" : + event instanceof $doc.defaultView.UIEvent ? "UIEvent" : + event instanceof $doc.defaultView.Event ? "Event" : "Unknown"; + resolve([event.target.value, '${entry.value}', eventInterface]); + }, { once: true }))""", + ) + } + } + + // Perform auto-fill and return number of auto-fills performed. + fun autoFillChild(id: Int, child: AccessibilityNodeInfo) { + // Seal the node info instance so we can perform actions on it. + if (child.childCount > 0) { + for (i in 0 until child.childCount) { + val childId = child.getChildId(i) + autoFillChild(childId, createNodeInfo(childId)) + } + } + + if (EditText::class.java.name == child.className) { + assertThat("Input should be enabled", child.isEnabled, equalTo(true)) + assertThat("Input should be focusable", child.isFocusable, equalTo(true)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Password type should match", + child.isPassword, + equalTo( + child.inputType == InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + ), + ) + } + + val args = Bundle(1) + val value = if (child.isPassword) { + "baz" + } else { + if (Build.VERSION.SDK_INT < 19) { + "bar" + } else { + when (child.inputType) { + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS, + -> "a@b.c" + InputType.TYPE_CLASS_NUMBER -> "24" + InputType.TYPE_CLASS_PHONE -> "42" + else -> "bar" + } + } + } + + val ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE = if (Build.VERSION.SDK_INT >= 21) { + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE + } else { + "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE" + } + val ACTION_SET_TEXT = if (Build.VERSION.SDK_INT >= 21) { + AccessibilityNodeInfo.ACTION_SET_TEXT + } else { + 0x200000 + } + + args.putCharSequence(ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, value) + assertThat( + "Can perform auto-fill", + provider.performAction(id, ACTION_SET_TEXT, args), + equalTo(true), + ) + } + } + + autoFillChild(View.NO_ID, createNodeInfo(View.NO_ID)) + + // Wait on the promises and check for correct values. + for ((actual, expected, eventInterface) in promises.map { it.value.asJSList<String>() }) { + assertThat("Auto-filled value must match", actual, equalTo(expected)) + assertThat("input event should be dispatched with InputEvent interface", eventInterface, equalTo("InputEvent")) + } + } + + @Test + fun autoFill_navigation() { + // Fails with BFCache in the parent. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1715480 + sessionRule.setPrefsUntilTestEnd( + mapOf( + "fission.bfcacheInParent" to false, + ), + ) + fun countAutoFillNodes( + cond: (AccessibilityNodeInfo) -> Boolean = + { it.className == "android.widget.EditText" }, + id: Int = View.NO_ID, + ): Int { + val info = createNodeInfo(id) + return ( + if (cond(info) && info.className != "android.webkit.WebView") { + 1 + } else { + 0 + } + ) + ( + if (info.childCount > 0) { + (0 until info.childCount).sumOf { + countAutoFillNodes(cond, info.getChildId(it)) + } + } else { + 0 + } + ) + } + + // XXX: Reliably waiting for iframes to load could be flaky, so we wait + // for our autofill nodes to be the right number. + fun waitForAutoFillNodes() { + val checkAutoFillNodes = object : EventDelegate, ShouldContinue { + var haveAllAutoFills = countAutoFillNodes() == 18 + + override fun shouldContinue(): Boolean = !haveAllAutoFills + + override fun onWinContentChanged(event: AccessibilityEvent) { + haveAllAutoFills = countAutoFillNodes() == 18 + } + } + if (checkAutoFillNodes.shouldContinue()) { + sessionRule.waitUntilCalled(checkAutoFillNodes) + } + } + + // Wait for the accessibility nodes to populate. + mainSession.loadTestPath(FORMS_HTML_PATH) + waitForInitialFocus() + waitForAutoFillNodes() + + assertThat( + "Initial auto-fill count should match", + countAutoFillNodes(), + equalTo(18), + ) + assertThat( + "Password auto-fill count should match", + countAutoFillNodes({ it.isPassword }), + equalTo(4), + ) + + // Now wait for the nodes to clear. + mainSession.loadTestPath(HELLO_HTML_PATH) + waitForInitialFocus() + assertThat( + "Should not have auto-fill fields", + countAutoFillNodes(), + equalTo(0), + ) + + // Now wait for the nodes to reappear. + mainSession.goBack() + waitForInitialFocus() + waitForAutoFillNodes() + assertThat( + "Should have auto-fill fields again", + countAutoFillNodes(), + equalTo(18), + ) + assertThat( + "Should not have focused field", + countAutoFillNodes({ it.isFocused }), + equalTo(0), + ) + + mainSession.evaluateJS("document.querySelector('#pass1').focus()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onFocused(event: AccessibilityEvent) { + } + }) + assertThat( + "Should have one focused field", + countAutoFillNodes({ it.isFocused }), + equalTo(1), + ) + + mainSession.evaluateJS("document.querySelector('#pass1').blur()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onFocused(event: AccessibilityEvent) { + } + }) + assertThat( + "Should not have focused field", + countAutoFillNodes({ it.isFocused }), + equalTo(0), + ) + } + + @Test + fun testTree() { + loadTestPage("test-tree") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 3 children", rootNode.childCount, equalTo(3)) + var rootBounds = Rect() + rootNode.getBoundsInScreen(rootBounds) + assertThat("Root node bounds are not empty", rootBounds.isEmpty, equalTo(false)) + assertThat("Root node is visible to user", rootNode.isVisibleToUser, equalTo(true)) + + var labelBounds = Rect() + val labelNode = createNodeInfo(rootNode.getChildId(0)) + labelNode.getBoundsInScreen(labelBounds) + + assertThat("Label bounds are in parent", rootBounds.contains(labelBounds), equalTo(true)) + assertThat("First node is a label", labelNode.className.toString(), equalTo("android.view.View")) + assertThat("Label has text", labelNode.text.toString(), equalTo("Name:")) + assertThat("Label node is visible to user", labelNode.isVisibleToUser, equalTo(true)) + + val entryNode = createNodeInfo(rootNode.getChildId(1)) + assertThat("Second node is an entry", entryNode.className.toString(), equalTo("android.widget.EditText")) + assertThat("Entry has vieIdwResourceName of 'name'", entryNode.viewIdResourceName, equalTo("name")) + assertThat("Entry value is text", entryNode.text.toString(), equalTo("Julie")) + assertThat("Entry node is visible to user", entryNode.isVisibleToUser, equalTo(true)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Entry hint is label", + entryNode.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("Name:"), + ) + assertThat( + "Entry input type is correct", + entryNode.inputType, + equalTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT), + ) + } + + val buttonNode = createNodeInfo(rootNode.getChildId(2)) + assertThat("Last node is a button", buttonNode.className.toString(), equalTo("android.widget.Button")) + // The child text leaf is pruned, so this button is childless. + assertThat("Button has a single text leaf", buttonNode.childCount, equalTo(0)) + assertThat("Button has correct text", buttonNode.text.toString(), equalTo("Submit")) + assertThat("Button is visible to user", buttonNode.isVisibleToUser, equalTo(true)) + } + + @Test fun testLoadUnloadIframeDoc() { + mainSession.loadTestPath(REMOTE_IFRAME) + waitForInitialFocus() + + loadTestPage("test-tree") + waitForInitialFocus() + + mainSession.loadTestPath(REMOTE_IFRAME) + waitForInitialFocus() + + loadTestPage("test-tree") + waitForInitialFocus() + + mainSession.loadTestPath(REMOTE_IFRAME) + waitForInitialFocus() + + loadTestPage("test-tree") + waitForInitialFocus() + } + + private fun testAccessibilityFocusIframe(page: String) { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(page) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Label has text", node.text.toString(), equalTo("Some stuff ")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("heading has correct content", node.text as String, equalTo("Hello, world!")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Label has text", node.text.toString(), equalTo("Some stuff ")) + } + }) + } + + @Test fun testRemoteAccessibilityFocusIframe() { + testAccessibilityFocusIframe(REMOTE_IFRAME) + } + + @Test fun testLocalAccessibilityFocusIframe() { + testAccessibilityFocusIframe(LOCAL_IFRAME) + } + + private fun testIframeTree(page: String) { + mainSession.loadTestPath(page) + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 2 children", rootNode.childCount, equalTo(2)) + var rootBounds = Rect() + rootNode.getBoundsInScreen(rootBounds) + assertThat("Root bounds are not empty", rootBounds.isEmpty, equalTo(false)) + + val labelNode = createNodeInfo(rootNode.getChildId(0)) + assertThat("First node has text", labelNode.text.toString(), equalTo("Some stuff ")) + + val iframeNode = createNodeInfo(rootNode.getChildId(1)) + assertThat("iframe has vieIdwResourceName of 'iframe'", iframeNode.viewIdResourceName, equalTo("iframe")) + assertThat("iframe has 1 child", iframeNode.childCount, equalTo(1)) + var iframeBounds = Rect() + iframeNode.getBoundsInScreen(iframeBounds) + assertThat("iframe bounds in root bounds", rootBounds.contains(iframeBounds), equalTo(true)) + + val innerDocNode = createNodeInfo(iframeNode.getChildId(0)) + assertThat("Inner doc has one child", innerDocNode.childCount, equalTo(1)) + var innerDocBounds = Rect() + innerDocNode.getBoundsInScreen(innerDocBounds) + assertThat("iframe bounds match inner doc bounds", iframeBounds.contains(innerDocBounds), equalTo(true)) + + val section = createNodeInfo(innerDocNode.getChildId(0)) + assertThat("section has one child", innerDocNode.childCount, equalTo(1)) + + val node = createNodeInfo(section.getChildId(0)) + assertThat("Text node has text", node.text as String, equalTo("Hello, world!")) + var nodeBounds = Rect() + node.getBoundsInScreen(nodeBounds) + assertThat("inner node in inner doc bounds", innerDocBounds.contains(nodeBounds), equalTo(true)) + } + + @Test + fun testRemoteIframeTree() { + testIframeTree(REMOTE_IFRAME) + } + + @Test + fun testLocalIframeTree() { + testIframeTree(LOCAL_IFRAME) + } + + @Test + fun testCollection() { + loadTestPage("test-collection") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 2 children", rootNode.childCount, equalTo(2)) + + val firstList = createNodeInfo(rootNode.getChildId(0)) + assertThat("First list has 2 children", firstList.childCount, equalTo(2)) + assertThat("List is a ListView", firstList.className.toString(), equalTo("android.widget.ListView")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("First list should have collectionInfo", firstList.collectionInfo, notNullValue()) + assertThat("First list has 2 rowCount", firstList.collectionInfo.rowCount, equalTo(2)) + assertThat("First list should not be hierarchical", firstList.collectionInfo.isHierarchical, equalTo(false)) + } + + val firstListFirstItem = createNodeInfo(firstList.getChildId(0)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("Item has collectionItemInfo", firstListFirstItem.collectionItemInfo, notNullValue()) + assertThat("Item has correct rowIndex", firstListFirstItem.collectionItemInfo.rowIndex, equalTo(0)) + } + + val secondList = createNodeInfo(rootNode.getChildId(1)) + assertThat("Second list has 1 child", secondList.childCount, equalTo(1)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("Second list should have collectionInfo", secondList.collectionInfo, notNullValue()) + assertThat("Second list has 2 rowCount", secondList.collectionInfo.rowCount, equalTo(1)) + assertThat("Second list should be hierarchical", secondList.collectionInfo.isHierarchical, equalTo(true)) + } + } + + @Test fun testNavigateListItems() { + loadTestPage("test-collection") + waitForInitialFocus() + var nodeId = View.NO_ID + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on text leaf", + node.text as String, + startsWith("One"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "first item is a text leaf", + node.extras.getCharSequence("AccessibilityNodeInfo.geckoRole")!!.toString(), + equalTo("text leaf"), + ) + } + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on link", + node.contentDescription as String, + startsWith("Two"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "second item is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.geckoRole")!!.toString(), + equalTo("link"), + ) + } + } + }) + } + + @Test + fun testRange() { + loadTestPage("test-range") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 3 children", rootNode.childCount, equalTo(3)) + + val firstRange = createNodeInfo(rootNode.getChildId(0)) + assertThat("Range has right label", firstRange.text.toString(), equalTo("Rating")) + assertThat("Range is SeekBar", firstRange.className.toString(), equalTo("android.widget.SeekBar")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("'Rating' has rangeInfo", firstRange.rangeInfo, notNullValue()) + assertThat("'Rating' has correct value", firstRange.rangeInfo.current, equalTo(4f)) + assertThat("'Rating' has correct max", firstRange.rangeInfo.max, equalTo(10f)) + assertThat("'Rating' has correct min", firstRange.rangeInfo.min, equalTo(1f)) + assertThat("'Rating' has correct range type", firstRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_INT)) + } + + val secondRange = createNodeInfo(rootNode.getChildId(1)) + assertThat("Range has right label", secondRange.text.toString(), equalTo("Stars")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("'Rating' has rangeInfo", secondRange.rangeInfo, notNullValue()) + assertThat("'Rating' has correct value", secondRange.rangeInfo.current, equalTo(4.5f)) + assertThat("'Rating' has correct max", secondRange.rangeInfo.max, equalTo(5f)) + assertThat("'Rating' has correct min", secondRange.rangeInfo.min, equalTo(1f)) + assertThat("'Rating' has correct range type", secondRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT)) + } + + val thirdRange = createNodeInfo(rootNode.getChildId(2)) + assertThat("Range has right label", thirdRange.text.toString(), equalTo("Percent")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("'Rating' has rangeInfo", thirdRange.rangeInfo, notNullValue()) + assertThat("'Rating' has correct value", thirdRange.rangeInfo.current, equalTo(0.83f)) + assertThat("'Rating' has correct max", thirdRange.rangeInfo.max, equalTo(1f)) + assertThat("'Rating' has correct min", thirdRange.rangeInfo.min, equalTo(0f)) + assertThat("'Rating' has correct range type", thirdRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_PERCENT)) + } + } + + @Test fun testLinksMovingByDefault() { + loadTestPage("test-links") + waitForInitialFocus() + var nodeId = View.NO_ID + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + startsWith("a with href"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with href is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with no attributes", + node.text as String, + startsWith("a with no attributes"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with no attributes is not a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo(""), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with name", + node.text as String, + startsWith("a with name"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with name is not a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo(""), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with onclick", + node.contentDescription as String, + startsWith("a with onclick"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with onclick is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on span with role link", + node.contentDescription as String, + startsWith("span with role link"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "span with role link is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + } + + @Test fun testLinksMovingByLink() { + loadTestPage("test-links") + waitForInitialFocus() + var nodeId = View.NO_ID + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + startsWith("a with href"), + ) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with onclick", + node.contentDescription as String, + startsWith("a with onclick"), + ) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on span with role link", + node.contentDescription as String, + startsWith("span with role link"), + ) + } + }) + } + + @Test fun testAriaComboBoxesMovingByDefault() { + loadTestPage("test-aria-comboboxes") + waitForInitialFocus() + var nodeId = View.NO_ID + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.0 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.0 combobox"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.1 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.1 combobox"), + ) + } + } + }) + } + + @Test fun testAriaComboBoxesMovingByControl() { + loadTestPage("test-aria-comboboxes") + waitForInitialFocus() + var nodeId = View.NO_ID + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "CONTROL") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.0 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.0 combobox"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.1 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.1 combobox"), + ) + } + } + }) + } + + @Test fun testAccessibilityFocusBoundaries() { + loadTestPage("test-links") + waitForInitialFocus() + var nodeId = View.NO_ID + var performedAction: Boolean + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus to first node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + startsWith("a with href"), + ) + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus past first node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + assertThat("Accessibility focus on web view", getSourceId(event), equalTo(View.NO_ID)) + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus to second node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with no attributes", + node.text as String, + startsWith("a with no attributes"), + ) + } + }) + + // hide first and last link + mainSession.evaluateJS("document.querySelectorAll('body > :first-child, body > :last-child').forEach(e => e.style.display = 'none');") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus past first visible node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + assertThat("Accessibility focus on web view", getSourceId(event), equalTo(View.NO_ID)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with name", + node.text as String, + startsWith("a with name"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with name is not a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo(""), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with onclick", + node.contentDescription as String, + startsWith("a with onclick"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with onclick is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Should fail to move a11y focus to last hidden node", performedAction, equalTo(false)) + + // show last link + mainSession.evaluateJS("document.querySelector('body > :last-child').style.display = 'initial';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on span with role link", + node.contentDescription as String, + startsWith("span with role link"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "span with role link is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Should fail to move a11y focus beyond last node", performedAction, equalTo(false)) + + performedAction = provider.performAction(View.NO_ID, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null) + assertThat("Should fail to move a11y focus before web content", performedAction, equalTo(false)) + } + + @Test fun testTextEntry() { + loadTestPage("test-text-entry-node") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').focus()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) {} + }) + + mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').value = 'Tobiasas'") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onTextChanged(event: AccessibilityEvent) {} + + @AssertCalled(count = 1) + override fun onTextSelectionChanged(event: AccessibilityEvent) {} + + // Don't fire a11y focus for collapsed caret changes. + // This will interfere with on screen keyboards and throw a11y focus + // back and fourth. + @AssertCalled(count = 0) + override fun onAccessibilityFocused(event: AccessibilityEvent) {} + }) + } +} |