// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.content.browser.accessibility;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_BOTTOM;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_HEIGHT;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_LEFT;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_RIGHT;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_TOP;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_WIDTH;
import android.os.Bundle;
import androidx.test.filters.MediumTest;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.BaseJUnit4ClassRunner;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.Features.DisableFeatures;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.content_public.browser.ContentFeatureList;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.content_shell_apk.ContentShellActivityTestRule;
import org.chromium.ui.accessibility.AccessibilityFeatures;
import java.util.concurrent.TimeoutException;
/** Tests for the implementation of onProvideVirtualStructure in WebContentsAccessibility. */
@RunWith(BaseJUnit4ClassRunner.class)
@DisableFeatures(ContentFeatureList.ACCESSIBILITY_UNIFIED_SNAPSHOTS)
public class AssistViewStructureTest {
@Rule
public ContentShellActivityTestRule mActivityTestRule = new ContentShellActivityTestRule();
/** Helper to call onProvideVirtualStructure and block until the results are received. */
private TestViewStructure getViewStructureFromHtml(String htmlContent, String js)
throws TimeoutException {
mActivityTestRule.launchContentShellWithUrl(UrlUtils.encodeHtmlDataUri(htmlContent));
mActivityTestRule.waitForActiveShellToBeDoneLoading();
if (js != null) {
JavaScriptUtils.executeJavaScriptAndWaitForResult(
mActivityTestRule.getWebContents(), js);
}
final WebContentsAccessibilityImpl wcax = mActivityTestRule.getWebContentsAccessibility();
TestViewStructure testViewStructure = new TestViewStructure();
ThreadUtils.runOnUiThreadBlocking(
() -> wcax.onProvideVirtualStructure(testViewStructure, false));
CriteriaHelper.pollUiThread(
wcax::hasFinishedLatestAccessibilitySnapshotForTesting,
"Timed out waiting for onProvideVirtualStructure");
return testViewStructure;
}
/** Call getViewStructureFromHtml without the js parameter. */
private TestViewStructure getViewStructureFromHtml(String htmlContent) throws TimeoutException {
return getViewStructureFromHtml(htmlContent, null);
}
private String getSelectionScript(String node1, int start, String node2, int end) {
return "var element1 = document.getElementById('"
+ node1
+ "');"
+ "var node1 = element1.childNodes.item(0);"
+ "var range=document.createRange();"
+ "range.setStart(node1,"
+ start
+ ");"
+ "var element2 = document.getElementById('"
+ node2
+ "');"
+ "var node2 = element2.childNodes.item(0);"
+ "range.setEnd(node2,"
+ end
+ ");"
+ "var selection=window.getSelection();"
+ "selection.removeAllRanges();"
+ "selection.addRange(range);";
}
private String addManyNodesScript() {
return "var body = document.getElementById('container');\n"
+ "for (i = 0; i < 600; i++) {\n"
+ " var nextContainer = document.createElement('div');\n"
+ " for (j = 0; j < 10; j++) {\n"
+ " var paragraph = document.createElement('p');\n"
+ " paragraph.innerHTML = \"Example Text\";\n"
+ " nextContainer.appendChild(paragraph);\n"
+ " }\n"
+ " body.appendChild(nextContainer);\n"
+ "}\n";
}
/** Test that the snapshot contains the url. */
@Test
@MediumTest
public void testUrl() throws Throwable {
TestViewStructure root = getViewStructureFromHtml("<p>Hello World</p>");
Assert.assertEquals(1, root.getChildCount());
TestViewStructure webview = root.getChild(0);
Assert.assertNotNull(webview);
Bundle extras = webview.getExtras();
String url = extras.getCharSequence("url").toString();
Assert.assertTrue(url.contains("data:"));
Assert.assertFalse(url.contains("http:"));
Assert.assertTrue(url.contains("text/html"));
Assert.assertTrue(url.contains("Hello"));
Assert.assertTrue(url.contains("World"));
}
/** Test selection is propagated when it spans one character. */
@Test
@MediumTest
public void testOneCharacterSelection() throws Throwable {
final String data = "<html><body><b id='node' role='none'>foo</b></body></html>";
final String js = getSelectionScript("node", 0, "node", 1);
TestViewStructure root = getViewStructureFromHtml(data, js).getChild(0);
Assert.assertEquals(1, root.getChildCount());
Assert.assertEquals("", root.getText());
TestViewStructure child = root.getChild(0);
TestViewStructure grandchild = child.getChild(0);
Assert.assertEquals("foo", grandchild.getText());
Assert.assertEquals(0, grandchild.getTextSelectionStart());
Assert.assertEquals(1, grandchild.getTextSelectionEnd());
}
/** Test selection is propagated when it spans one node. */
@Test
@MediumTest
public void testOneNodeSelection() throws Throwable {
final String data = "<html><body><b id='node' role='none'>foo</b></body></html>";
final String js = getSelectionScript("node", 0, "node", 3);
TestViewStructure root = getViewStructureFromHtml(data, js).getChild(0);
Assert.assertEquals(1, root.getChildCount());
Assert.assertEquals("", root.getText());
TestViewStructure child = root.getChild(0);
TestViewStructure grandchild = child.getChild(0);
Assert.assertEquals("foo", grandchild.getText());
Assert.assertEquals(0, grandchild.getTextSelectionStart());
Assert.assertEquals(3, grandchild.getTextSelectionEnd());
}
/** Test selection is propagated when it spans to the beginning of the next node. */
@Test
@MediumTest
public void testSubsequentNodeSelection() throws Throwable {
final String data =
"<html><body><b id='node1' role='none'>foo</b>"
+ "<b id='node2' role='none'>bar</b></body></html>";
final String js = getSelectionScript("node1", 1, "node2", 1);
TestViewStructure root = getViewStructureFromHtml(data, js).getChild(0);
Assert.assertEquals(1, root.getChildCount());
Assert.assertEquals("", root.getText());
TestViewStructure child = root.getChild(0);
TestViewStructure grandchild = child.getChild(0);
Assert.assertEquals("foo", grandchild.getText());
Assert.assertEquals(1, grandchild.getTextSelectionStart());
Assert.assertEquals(3, grandchild.getTextSelectionEnd());
grandchild = child.getChild(1);
Assert.assertEquals("bar", grandchild.getText());
Assert.assertEquals(0, grandchild.getTextSelectionStart());
Assert.assertEquals(1, grandchild.getTextSelectionEnd());
}
/** Test selection is propagated across multiple nodes. */
@Test
@MediumTest
public void testMultiNodeSelection() throws Throwable {
final String data =
"<html><body><b id='node1' role='none'>foo</b><b>middle</b>"
+ "<b id='node2' role='none'>bar</b></body></html>";
final String js = getSelectionScript("node1", 1, "node2", 1);
TestViewStructure root = getViewStructureFromHtml(data, js).getChild(0);
Assert.assertEquals(1, root.getChildCount());
Assert.assertEquals("", root.getText());
TestViewStructure child = root.getChild(0);
TestViewStructure grandchild = child.getChild(0);
Assert.assertEquals("foo", grandchild.getText());
Assert.assertEquals(1, grandchild.getTextSelectionStart());
Assert.assertEquals(3, grandchild.getTextSelectionEnd());
grandchild = child.getChild(1);
Assert.assertEquals("middle", grandchild.getText());
Assert.assertEquals(0, grandchild.getTextSelectionStart());
Assert.assertEquals(6, grandchild.getTextSelectionEnd());
grandchild = child.getChild(2);
Assert.assertEquals("bar", grandchild.getText());
Assert.assertEquals(0, grandchild.getTextSelectionStart());
Assert.assertEquals(1, grandchild.getTextSelectionEnd());
}
/** Test selection is propagated from an HTML input element. */
@Test
@MediumTest
public void testRequestAccessibilitySnapshotInputSelection() throws Throwable {
final String data = "<html><body><input id='input' value='Hello, world'></body></html>";
final String js =
"var input = document.getElementById('input');"
+ "input.select();"
+ "input.selectionStart = 0;"
+ "input.selectionEnd = 5;";
TestViewStructure root = getViewStructureFromHtml(data, js).getChild(0);
Assert.assertEquals(1, root.getChildCount());
Assert.assertEquals("", root.getText());
TestViewStructure child = root.getChild(0);
TestViewStructure grandchild = child.getChild(0);
Assert.assertEquals("Hello, world", grandchild.getText());
Assert.assertEquals(0, grandchild.getTextSelectionStart());
Assert.assertEquals(5, grandchild.getTextSelectionEnd());
}
/** Test that the snapshot always contains Bundle extras for unclipped bounds. */
@Test
@MediumTest
public void testUnclippedBounds() throws Throwable {
TestViewStructure root = getViewStructureFromHtml("<p>Hello world</p>").getChild(0);
TestViewStructure paragraph = root.getChild(0);
Bundle extras = paragraph.getExtras();
int unclippedTop = extras.getInt(EXTRAS_KEY_UNCLIPPED_TOP, -1);
int unclippedBottom = extras.getInt(EXTRAS_KEY_UNCLIPPED_BOTTOM, -1);
int unclippedLeft = extras.getInt(EXTRAS_KEY_UNCLIPPED_LEFT, -1);
int unclippedRight = extras.getInt(EXTRAS_KEY_UNCLIPPED_RIGHT, -1);
int unclippedWidth = extras.getInt(EXTRAS_KEY_UNCLIPPED_WIDTH, -1);
int unclippedHeight = extras.getInt(EXTRAS_KEY_UNCLIPPED_HEIGHT, -1);
Assert.assertTrue(unclippedTop > 0);
Assert.assertTrue(unclippedBottom > 0);
Assert.assertTrue(unclippedLeft > 0);
Assert.assertTrue(unclippedRight > 0);
Assert.assertTrue(unclippedWidth > 0);
Assert.assertTrue(unclippedHeight > 0);
}
/** Test that pages with larger than the max node count result in a partial tree. */
@Test
@MediumTest
@DisableFeatures(AccessibilityFeatures.ACCESSIBILITY_SNAPSHOT_STRESS_TESTS)
public void testMaxNodesLimit() throws Throwable {
var histogramWatcher =
HistogramWatcher.newBuilder()
.expectAnyRecordTimes(
"Accessibility.AXTreeSnapshotter.Snapshot.EndToEndRuntime", 0)
.build();
// There is a max of 5000 nodes, add many nodes with some children. If the tree is flat
// then all nodes will end up serialized because the serializer will finish the current
// node and its children. The number of nodes returned may be more or less than 5000.
TestViewStructure root =
getViewStructureFromHtml("<div id='container'></div>", addManyNodesScript())
.getChild(0);
// Recursively count child nodes. Allow for approximately 5000 nodes.
Assert.assertTrue(
String.format(
"Too many nodes serialized, found %s", root.getTotalDescendantCount()),
5100 > root.getTotalDescendantCount());
histogramWatcher.assertExpected();
}
/** Test that pages with more than the max node count return a full tree during stress tests. */
@Test
@MediumTest
@EnableFeatures(AccessibilityFeatures.ACCESSIBILITY_SNAPSHOT_STRESS_TESTS)
@DisabledTest(message = "crbug.com/362208929")
public void testMaxNodesLimit_ignoredDuringStressTests() throws Throwable {
var histogramWatcher =
HistogramWatcher.newBuilder()
.expectAnyRecordTimes(
"Accessibility.AXTreeSnapshotter.Snapshot.EndToEndRuntime", 1)
.build();
TestViewStructure root =
getViewStructureFromHtml("<div id='container'></div>", addManyNodesScript())
.getChild(0);
Assert.assertTrue(
String.format("Too few nodes serialized, found %s", root.getTotalDescendantCount()),
12000 < root.getTotalDescendantCount());
histogramWatcher.assertExpected();
}
}