chromium/content/public/android/javatests/src/org/chromium/content/browser/accessibility/AccessibilityContentShellActivityTestRule.java

// 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.AccessibilityContentShellTestUtils.ANP_ERROR;
import static org.chromium.content.browser.accessibility.AccessibilityContentShellTestUtils.END_OF_TEST_ERROR;
import static org.chromium.content.browser.accessibility.AccessibilityContentShellTestUtils.NODE_TIMEOUT_ERROR;
import static org.chromium.content.browser.accessibility.AccessibilityContentShellTestUtils.READY_FOR_TEST_ERROR;
import static org.chromium.content.browser.accessibility.AccessibilityContentShellTestUtils.sContentShellDelegate;
import static org.chromium.ui.accessibility.AccessibilityState.EVENT_TYPE_MASK_ALL;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.EVENT_TYPE_MASK;

import android.annotation.SuppressLint;
import android.os.Bundle;
import android.os.Environment;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;

import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat;

import org.hamcrest.Matchers;
import org.junit.After;
import org.junit.Assert;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.content_shell_apk.ContentShellActivityTestRule;
import org.chromium.ui.accessibility.AccessibilityState;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;

/** Custom activity test rule for any content shell tests related to accessibility. */
@SuppressLint("VisibleForTests")
public class AccessibilityContentShellActivityTestRule extends ContentShellActivityTestRule {
    // Test output error messages.
    protected static final String EVENTS_ERROR =
            "Generated events and actions did not match expectations.";
    protected static final String NODE_ERROR =
            "Generated AccessibilityNodeInfo tree did not match expectations.";
    protected static final String EXPECTATIONS_NULL =
            "Test expectations were null, perhaps the file is missing? Create an empty file for "
                    + "both the -external and -assist-data tests.";
    protected static final String RESULTS_NULL =
            "Test results were null, did you add the tracker to WebContentsAccessibilityImpl?";
    protected static final String MISSING_FILE_ERROR =
            "Input file could not be read, perhaps the file is missing?";

    // Member variables required for testing framework. Although they are the same object, we will
    // instantiate an object of type |AccessibilityNodeProvider| for convenience.
    protected static final String BASE_DIRECTORY = "/chromium_tests_root";
    public AccessibilityNodeProviderCompat mNodeProvider;
    public WebContentsAccessibilityImpl mWcax;

    // Tracker for all events and actions performed during a given test.
    private AccessibilityActionAndEventTracker mTracker;

    public AccessibilityContentShellActivityTestRule() {
        super();
    }

    /**
     * Helper methods for setup of a basic web contents accessibility unit test.
     *
     * This method replaces the usual setUp() method annotated with @Before because we wish to
     * load different data with each test, but the process is the same for all tests.
     *
     * Leaving a commented @Before annotation on each method as a reminder/context clue.
     */
    /* @Before */
    protected void setupTestFromFile(String file) {
        // Verify file exists before beginning the test.
        verifyInputFile(file);

        launchContentShellWithUrl(UrlUtils.getIsolatedTestFileUrl(file));
        waitForActiveShellToBeDoneLoading();
        setupTestFramework();
        setAccessibilityDelegate();

        // To prevent flakes, do not disable accessibility mid tests.
        mWcax.setIsAutoDisableAccessibilityCandidateForTesting(false);

        sendReadyForTestSignal();
    }

    /**
     * Helper method to set up our tests. This method replaces the @Before method. Leaving a
     * commented @Before annotation on method as a reminder/context clue.
     */
    /* @Before */
    public void setupTestFramework() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    AccessibilityState.setIsAnyAccessibilityServiceEnabledForTesting(true);
                    AccessibilityState.setIsScreenReaderEnabledForTesting(true);
                    AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK, EVENT_TYPE_MASK_ALL);
                });

        mWcax = getWebContentsAccessibility();
        mNodeProvider = getAccessibilityNodeProvider();

        mTracker = new AccessibilityActionAndEventTracker();
        mWcax.setAccessibilityTrackerForTesting(mTracker);
    }

    public void setupTestFrameworkForBasicMode() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    AccessibilityState.setIsAnyAccessibilityServiceEnabledForTesting(true);
                    AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK, EVENT_TYPE_MASK_ALL);
                });

        mWcax = getWebContentsAccessibility();
        mNodeProvider = getAccessibilityNodeProvider();

        mTracker = new AccessibilityActionAndEventTracker();
        mWcax.setAccessibilityTrackerForTesting(mTracker);
    }

    public void setupTestFrameworkForFormControlsMode() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    AccessibilityState.setIsAnyAccessibilityServiceEnabledForTesting(true);
                    AccessibilityState.setIsOnlyPasswordManagersEnabledForTesting(true);
                    AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK, EVENT_TYPE_MASK_ALL);
                });

        mWcax = getWebContentsAccessibility();
        mNodeProvider = getAccessibilityNodeProvider();

        mTracker = new AccessibilityActionAndEventTracker();
        mWcax.setAccessibilityTrackerForTesting(mTracker);
    }

    /** Helper method to tear down our tests so we can start the next test clean. */
    @After
    public void tearDown() {
        // Always reset our max events for good measure.
        if (mWcax != null) {
            mWcax.setMaxContentChangedEventsToFireForTesting(-1);
        }

        AccessibilityContentShellTestData.resetData();
    }

    /**
     * Returns the current |AccessibilityNodeProvider| from the WebContentsAccessibilityImpl
     * instance. Use polling to ensure a non-null value before returning.
     */
    private AccessibilityNodeProviderCompat getAccessibilityNodeProvider() {
        CriteriaHelper.pollUiThread(
                () -> mWcax.getAccessibilityNodeProviderCompat() != null, ANP_ERROR);
        return mWcax.getAccessibilityNodeProviderCompat();
    }

    /**
     * Helper method to call AccessibilityNodeInfo.getChildId and convert to a virtual
     * view ID using reflection, since the needed methods are hidden.
     */
    protected int getChildId(AccessibilityNodeInfoCompat node, int index) {
        try {
            // The methods found through reflection are only available in |AccessibilityNodeInfo|,
            // so we will unwrap |node| to perform the calls.
            AccessibilityNodeInfo nodeInfo = (AccessibilityNodeInfo) node.getInfo();
            Method getChildIdMethod =
                    AccessibilityNodeInfo.class.getMethod("getChildId", int.class);
            long childId = (long) getChildIdMethod.invoke(nodeInfo, Integer.valueOf(index));
            Method getVirtualDescendantIdMethod =
                    AccessibilityNodeInfo.class.getMethod("getVirtualDescendantId", long.class);
            int virtualViewId =
                    (int) getVirtualDescendantIdMethod.invoke(null, Long.valueOf(childId));
            return virtualViewId;
        } catch (Exception ex) {
            Assert.fail(
                    "Unable to call hidden AccessibilityNodeInfoCompat method: " + ex.toString());
            return 0;
        }
    }

    /**
     * Helper method to recursively search a tree of virtual views under an
     * AccessibilityNodeProvider and return one whose text or contentDescription equals |text|.
     * Returns the virtual view ID of the matching node, if found, and View.NO_ID if not.
     */
    private <T> int findNodeMatching(
            int virtualViewId,
            AccessibilityContentShellTestUtils.AccessibilityNodeInfoMatcher<T> matcher,
            T element) {
        AccessibilityNodeInfoCompat node = mNodeProvider.createAccessibilityNodeInfo(virtualViewId);
        Assert.assertNotEquals(node, null);

        if (matcher.matches(node, element)) return virtualViewId;

        for (int i = 0; i < node.getChildCount(); i++) {
            int childId = getChildId(node, i);
            AccessibilityNodeInfoCompat child = mNodeProvider.createAccessibilityNodeInfo(childId);
            if (child != null) {
                int result = findNodeMatching(childId, matcher, element);
                if (result != View.NO_ID) return result;
            }
        }

        return View.NO_ID;
    }

    /**
     * Helper method to block until findNodeMatching() returns a valid node matching
     * the given criteria. Returns the virtual view ID of the matching node, if found, and
     * asserts if not.
     */
    public <T> int waitForNodeMatching(
            AccessibilityContentShellTestUtils.AccessibilityNodeInfoMatcher<T> matcher, T element) {
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            findNodeMatching(View.NO_ID, matcher, element),
                            Matchers.not(View.NO_ID));
                });

        int virtualViewId =
                ThreadUtils.runOnUiThreadBlocking(
                        () -> findNodeMatching(View.NO_ID, matcher, element));
        Assert.assertNotEquals(View.NO_ID, virtualViewId);
        return virtualViewId;
    }

    /**
     * Helper method to perform actions on the UI so we can then send accessibility events
     *
     * @param viewId int virtualViewId of the given node
     * @param action int desired AccessibilityNodeInfo action
     * @param args Bundle action bundle
     * @return boolean return value of performAction
     * @throws ExecutionException Error
     */
    public boolean performActionOnUiThread(int viewId, int action, Bundle args)
            throws ExecutionException {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> mNodeProvider.performAction(viewId, action, args));
    }

    /**
     * Helper method to perform an action on the UI, then poll for a given criteria to verify
     * the action was completed.
     *
     * @param viewId int                   virtualViewId of the given node
     * @param action int                   desired AccessibilityNodeInfo action
     * @param args Bundle                  action bundle
     * @param criteria Callable<Boolean>   criteria to poll against to verify completion
     * @return boolean                     return value of performAction
     * @throws ExecutionException          Error
     * @throws Throwable                   Error
     */
    public boolean performActionOnUiThread(
            int viewId, int action, Bundle args, Callable<Boolean> criteria)
            throws ExecutionException, Throwable {
        boolean returnValue = performActionOnUiThread(viewId, action, args);
        CriteriaHelper.pollUiThread(criteria, NODE_TIMEOUT_ERROR);
        return returnValue;
    }

    /** Helper method for executing a given JS method for the current web contents. */
    public void executeJS(String method) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> getWebContents().evaluateJavaScriptForTests(method, null));
    }

    /**
     * Helper method to focus a given node.
     *
     * @param virtualViewId     The virtualViewId of the node to focus
     * @throws Throwable        Error
     */
    public void focusNode(int virtualViewId) throws Throwable {
        // Focus given node, assert actions were performed, then poll until node is updated.
        Assert.assertTrue(
                performActionOnUiThread(
                        virtualViewId, AccessibilityNodeInfoCompat.ACTION_FOCUS, null));
        Assert.assertTrue(
                performActionOnUiThread(
                        virtualViewId,
                        AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS,
                        null));
        ThreadUtils.runOnUiThreadBlocking(
                () -> mNodeProvider.createAccessibilityNodeInfo(virtualViewId));

        CriteriaHelper.pollUiThread(
                () -> {
                    return mNodeProvider
                            .createAccessibilityNodeInfo(virtualViewId)
                            .isAccessibilityFocused();
                },
                NODE_TIMEOUT_ERROR);
    }

    /**
     * Helper method for setting standard AccessibilityDelegate. The delegate is set on the parent
     * as WebContentsAccessibilityImpl sends events using the parent.
     */
    public void setAccessibilityDelegate() {
        ((ViewGroup) getContainerView().getParent())
                .setAccessibilityDelegate(sContentShellDelegate);
    }

    /**
     * Call through the WebContentsAccessibilityImpl to send a signal that we are ready to begin a
     * test (using the kEndOfTest signal for simplicity). Poll until we receive the generated Blink
     * event in response, then reset the tracker.
     */
    public void sendReadyForTestSignal() {
        ThreadUtils.runOnUiThreadBlocking(() -> mWcax.signalEndOfTestForTesting());
        CriteriaHelper.pollUiThread(() -> mTracker.testComplete(), READY_FOR_TEST_ERROR);
        ThreadUtils.runOnUiThreadBlocking(() -> mTracker.signalReadyForTest());
    }

    /**
     * Call through the WebContentsAccessibilityImpl to send a kEndOfTest event to signal that we
     * are done with a test. Poll until we receive the generated Blink event in response.
     */
    public void sendEndOfTestSignal() {
        ThreadUtils.runOnUiThreadBlocking(() -> mWcax.signalEndOfTestForTesting());
        CriteriaHelper.pollUiThread(() -> mTracker.testComplete(), END_OF_TEST_ERROR);
    }

    /**
     * Helper method to generate results from the |AccessibilityActionAndEventTracker|.
     *
     * @return          String      List of all actions and events performed during test.
     */
    public String getTrackerResults() {
        return mTracker.results();
    }

    /**
     * Read the contents of a file, and return as a String.
     *
     * @param file                  File to read (including path and name)
     * @return String               Contents of the given file.
     */
    protected String readExpectationFile(String file) {
        String directory = Environment.getExternalStorageDirectory().getPath() + BASE_DIRECTORY;

        try {
            File expectedFile = new File(directory, "/" + file);
            FileInputStream fis = new FileInputStream(expectedFile);

            byte[] data = new byte[(int) expectedFile.length()];
            fis.read(data);
            fis.close();

            return new String(data);
        } catch (IOException e) {
            throw new AssertionError(EXPECTATIONS_NULL, e);
        }
    }

    /**
     * Check that a given file exists on disk.
     *
     * @param file                  String - file to check, including path and name
     */
    protected void verifyInputFile(String file) {
        String directory = Environment.getExternalStorageDirectory().getPath() + BASE_DIRECTORY;

        File expectedFile = new File(directory, "/" + file);
        Assert.assertTrue(
                MISSING_FILE_ERROR
                        + " could not find the directory: "
                        + directory
                        + ", and/or file: "
                        + expectedFile.getPath(),
                expectedFile.exists());
    }
}