chromium/chrome/browser/resources/chromeos/accessibility/common/automation_util.ts

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Utilities for the automation extension API.
 */

import {AutomationPredicate} from './automation_predicate.js';
import {constants} from './constants.js';
import {TestImportManager} from './testing/test_import_manager.js';
import {AutomationTreeWalker, AutomationTreeWalkerRestriction} from './tree_walker.js';

type AutomationNode = chrome.automation.AutomationNode;
const HasPopup = chrome.automation.HasPopup;
const RoleType = chrome.automation.RoleType;

export class AutomationUtil {
  /**
   * Find a node in subtree of |cur| satisfying |pred| using pre-order
   * traversal.
   * @param cur Node to begin the search from.
   * @param pred A predicate to apply to a candidate node.
   * @return the node found, or null if none was found.
   */
  static findNodePre(
      cur: AutomationNode, dir: constants.Dir,
      pred: AutomationPredicate.Unary): AutomationNode|null {
    if (!cur) {
      return null;
    }

    if (pred(cur) && !AutomationPredicate.shouldIgnoreNode(cur)) {
      return cur;
    }

    let child = dir === constants.Dir.BACKWARD ? cur.lastChild : cur.firstChild;
    while (child) {
      const ret = AutomationUtil.findNodePre(child, dir, pred);
      if (ret) {
        return ret;
      }
      child = dir === constants.Dir.BACKWARD ? child.previousSibling :
                                               child.nextSibling;
    }
    return null;
  }

  /**
   * For a given automation property, return true if the value
   * represents something 'truthy', e.g.: for checked:
   * 'true'|'mixed' -> true
   * 'false'|undefined -> false
   * @return True if the value represents something 'truthy'.
   */
  static isTruthy(node: AutomationNode, attrib: string): boolean {
    if (!node) {
      return false;
    }
    switch (attrib) {
      case 'checked':
        return Boolean(node.checked) && node.checked !== 'false';
      case 'hasPopup':
        return Boolean(node.hasPopup) && node.hasPopup !== HasPopup.FALSE;

      // Chrome automatically calculates these attributes.
      case 'posInSet':
        // TODO(b/314203187): node.htmlAttributes may be undefined.
        return Boolean(node.htmlAttributes!['aria-posinset']) ||
            (node.root!.role !== RoleType.ROOT_WEB_AREA &&
             Boolean(node.posInSet));
      case 'setSize':
        // TODO(b/314203187): node.htmlAttributes may be undefined.
        return Boolean(node.htmlAttributes!['aria-setsize']) ||
            Boolean(node.setSize);

      // These attributes default to false for empty strings.
      case 'roleDescription':
        return Boolean(node.roleDescription);
      case 'value':
        return Boolean(node.value);
      case 'selected':
        return node.selected === true;
      default:
        // @ts-ignore: TODO(b/267329383): expression of type 'string' can't be
        // used to index type 'AutomationNode'.
        return node[attrib] !== undefined || Boolean(node.state[(attrib)]);
    }
  }

  /**
   * For a given automation property, return true if the value
   * represents something 'falsey', e.g.: for selected:
   * node.selected === false
   * @return If it represents something 'falsey'.
   */
  static isFalsey(node: AutomationNode, attrib: string): boolean {
    if (!node) {
      return false;
    }
    switch (attrib) {
      case 'selected':
        return node.selected === false;
      default:
        return !AutomationUtil.isTruthy(node, attrib);
    }
  }


  /**
   * Find a node in subtree of |cur| satisfying |pred| using post-order
   * traversal.
   * @param cur Node to begin the search from.
   * @param pred A predicate to apply
   *     to a candidate node.
   * @return The node found or null
   */
  static findNodePost(
      cur: AutomationNode, dir: constants.Dir,
      pred: AutomationPredicate.Unary): AutomationNode|null {
    if (!cur) {
      return null;
    }

    let child = dir === constants.Dir.BACKWARD ? cur.lastChild : cur.firstChild;
    while (child) {
      const ret = AutomationUtil.findNodePost(child, dir, pred);
      if (ret) {
        return ret;
      }
      child = dir === constants.Dir.BACKWARD ? child.previousSibling :
                                               child.nextSibling;
    }

    if (pred(cur) && !AutomationPredicate.shouldIgnoreNode(cur)) {
      return cur;
    }

    return null;
  }

  /**
   * Find the next node in the given direction in depth first order.
   *
   * Let D be the dfs linearization of |cur.root|. Then, let F be the list after
   * applying |pred| as a filter to D. This method will return the directed next
   * node of |cur| in F.
   * The restrictions option will further filter F. For example,
   * |skipInitialSubtree| will remove any |pred| matches in the subtree of |cur|
   * from F.
   * @param cur Node to begin the search from.
   * @param pred A predicate to apply to a candidate node.
   * @param optRestrictions |leaf|, |root|,
   *     |skipInitialAncestry|, and |skipInitialSubtree| are valid restrictions
   *     used when finding the next node.
   *     By default:
   *        the root predicate gets set to |AutomationPredicate.root|.
   *        |skipInitialSubtree| is false if |cur| is a container or matches
   *        |pred|. This alleviates the caller from syncing forwards.
   *        Leaves are nodes matched by |pred| which are not also containers.
   *        This takes care of syncing backwards.
   * @return The next node found
   */
  static findNextNode(
      cur: AutomationNode, dir: constants.Dir, pred: AutomationPredicate.Unary,
      optRestrictions?: AutomationTreeWalkerRestriction): AutomationNode|null {
    const walker = createWalker(cur, dir, pred, optRestrictions);
    return walker.next().node;
  }

  /**
   * Finds all nodes in the given direction in depth first order.
   *
   * Let D be the dfs linearization of |cur.root|. Then, let F be the list after
   * applying |pred| as a filter to D. This method will return the directed next
   * node of |cur| in F.
   * The restrictions option will further filter F. For example,
   * |skipInitialSubtree| will remove any |pred| matches in the subtree of |cur|
   * from F.
   * @param cur Node to begin the search from.
   * @param pred A predicate to apply to a candidate node.
   * @param optRestrictions |leaf|, |root|,
   *     |skipInitialAncestry|, and |skipInitialSubtree| are valid restrictions
   *     used when finding the next node.
   *     By default:
   *        the root predicate gets set to |AutomationPredicate.root|.
   *        |skipInitialSubtree| is false if |cur| is a container or matches
   *        |pred|. This alleviates the caller from syncing forwards.
   *        Leaves are nodes matched by |pred| which are not also containers.
   *        This takes care of syncing backwards.
   * @return All the nodes found.
   */
  static findAllNodes(
      cur: AutomationNode, dir: constants.Dir, pred: AutomationPredicate.Unary,
      optRestrictions?: AutomationTreeWalkerRestriction): AutomationNode[] {
    const walker = createWalker(cur, dir, pred, optRestrictions);
    const nodes = [];
    let currentNode = walker.next().node;
    while (currentNode) {
      nodes.push(currentNode);
      currentNode = walker.next().node;
    }
    return nodes;
  }

  /**
   * Given nodes a_1, ..., a_n starting at |cur| in pre order traversal, apply
   * |pred| to a_i and a_(i - 1) until |pred| is satisfied.  Returns a_(i - 1)
   * or a_i (depending on optBefore) or null if no match was found.
   * @param optBefore True to return a_(i - 1); a_i otherwise.
   *                  Defaults to false.
   * @return The node found.
   */
  static findNodeUntil(
      cur: AutomationNode, dir: constants.Dir, pred: AutomationPredicate.Binary,
      optBefore?: boolean): AutomationNode|null {
    let before = cur;
    let after: AutomationNode|null = before;
    do {
      before = after;
      after =
          AutomationUtil.findNextNode(before, dir, AutomationPredicate.leaf);
    } while (after && !pred(before, after));
    return optBefore ? before : after;
  }

  /**
   * Returns an array containing ancestors of node starting at root down to
   * node.
   * @return The array of ancestors found.
   */
  static getAncestors(node: AutomationNode): AutomationNode[] {
    const ret = [];
    let candidate: AutomationNode|undefined = node;
    while (candidate) {
      ret.push(candidate);

      candidate = candidate.parent;
    }
    return ret.reverse();
  }

  /**
   * Finds the lowest ancestor with a given role.
   * @return The ancestor found.
   */
  static getFirstAncestorWithRole(
      node: AutomationNode, role: chrome.automation.RoleType): AutomationNode
      |null {
    if (!node.parent) {
      return null;
    }
    if (node.parent.role === role) {
      return node.parent;
    }
    return AutomationUtil.getFirstAncestorWithRole(node.parent, role);
  }

  /**
   * Gets the first index where the two input arrays differ. Returns -1 if they
   * do not.
   * @return The index or -1.
   */
  static getDivergence(
      ancestorsA: AutomationNode[], ancestorsB: AutomationNode[]): number {
    for (let i = 0; i < ancestorsA.length; i++) {
      if (ancestorsA[i] !== ancestorsB[i]) {
        return i;
      }
    }
    if (ancestorsA.length === ancestorsB.length) {
      return -1;
    }
    return ancestorsA.length;
  }

  /**
   * Returns ancestors of |node| that are not also ancestors of |prevNode|.
   * @return The ancestors found.
   */
  static getUniqueAncestors(prevNode: AutomationNode, node: AutomationNode):
      AutomationNode[] {
    const prevAncestors = AutomationUtil.getAncestors(prevNode);
    const ancestors = AutomationUtil.getAncestors(node);
    const divergence = AutomationUtil.getDivergence(prevAncestors, ancestors);
    return ancestors.slice(divergence);
  }

  /**
   * Given |nodeA| and |nodeB| in that order, determines their ordering in the
   * document.
   * @return The direction representing the ordering.
   */
  static getDirection(nodeA: AutomationNode, nodeB: AutomationNode):
      constants.Dir {
    const ancestorsA = AutomationUtil.getAncestors(nodeA);
    const ancestorsB = AutomationUtil.getAncestors(nodeB);
    const divergence = AutomationUtil.getDivergence(ancestorsA, ancestorsB);

    // Default to constants.Dir.FORWARD.
    if (divergence === -1) {
      return constants.Dir.FORWARD;
    }

    const divA = ancestorsA[divergence];
    const divB = ancestorsB[divergence];

    // One of the nodes is an ancestor of the other. Order this relationship in
    // the same way dfs would. nodeA <= nodeB if nodeA is a descendant of
    // nodeB. nodeA > nodeB if nodeB is a descendant of nodeA.

    if (!divA) {
      return constants.Dir.FORWARD;
    }
    if (!divB) {
      return constants.Dir.BACKWARD;
    }
    if (divA.parent === nodeB) {
      return constants.Dir.BACKWARD;
    }
    if (divB.parent === nodeA) {
      return constants.Dir.FORWARD;
    }

    // TODO(b/267329383): indexInParent may be undefined.
    return divA.indexInParent! <= divB.indexInParent!? constants.Dir.FORWARD :
                                                       constants.Dir.BACKWARD;
  }

  /**
   * Determines whether the two given nodes come from the same tree source.
   */
  static isInSameTree(a: AutomationNode, b: AutomationNode): boolean {
    if (!a || !b) {
      return true;
    }

    // Given two non-desktop roots, consider them in the "same" tree.
    return a.root === b.root ||
        // TODO(b/267329383): a.root and b.root may be undefined.
        (a.root!.role === b.root!.role &&
         a.root!.role === RoleType.ROOT_WEB_AREA);
  }

  /**
   * Determines whether or not a node is or is the descendant of another node.
   * @return Whether the node is a descendant of the other node.
   */
  static isDescendantOf(node: AutomationNode, ancestor: AutomationNode):
      boolean {
    let testNode: AutomationNode|undefined = node;
    while (testNode && testNode !== ancestor) {
      testNode = testNode.parent;
    }
    return testNode === ancestor;
  }

  /**
   * Finds the deepest node containing the point. Since the automation tree does
   * not maintain a containment invariant when considering child node bounding
   * rects with respect to their parents, the hit test considers all children
   * before their parents when looking for a matching node.
   * @param node Subtree to search.
   * @return The deepest node containing the point.
   */
  static hitTest(node: AutomationNode, point: constants.Point): AutomationNode
      |null {
    let child = node.firstChild;
    while (child) {
      const hit = AutomationUtil.hitTest(child, point);
      if (hit) {
        return hit;
      }
      child = child.nextSibling;
    }

    const loc = node.unclippedLocation;

    // When |node| is partially or fully offscreen, try to find a better match.
    // TODO(b/267329383): loc may be undefined.
    if (loc!.left < 0 || loc!.top < 0) {
      return null;
    }

    if (point.x <= (loc!.left + loc!.width) && point.x >= loc!.left &&
        point.y <= (loc!.top + loc!.height) && point.y >= loc!.top) {
      return node;
    }
    return null;
  }

  /**
   * Gets a top level root.
   * @return The top level root.
   */
  static getTopLevelRoot(node: AutomationNode): AutomationNode|null {
    let root = node.root;
    if (!root || root.role === RoleType.DESKTOP) {
      return null;
    }

    while (root && root.parent && root.parent.root &&
           root.parent.root.role !== RoleType.DESKTOP) {
      root = root.parent.root;
    }
    return root;
  }

  /**
   * @return The least common ancestor of the two nodes.
   */
  static getLeastCommonAncestor(prevNode: AutomationNode, node: AutomationNode):
      AutomationNode|undefined {
    if (prevNode === node) {
      return node;
    }

    const prevAncestors = AutomationUtil.getAncestors(prevNode);
    const ancestors = AutomationUtil.getAncestors(node);
    const divergence = AutomationUtil.getDivergence(prevAncestors, ancestors);
    return ancestors[divergence - 1]!;
  }

  /**
   * Gets the accessible text for this node based on its role.
   * This text is suitable for caret navigation and selection in the node.
   * @return The accessible text.
   */
  static getText(node: AutomationNode): string {
    if (!node) {
      return '';
    }

    if (node.role === RoleType.TEXT_FIELD) {
      return node.value || '';
    }
    return node.name || '';
  }

  /**
   * Gets the root of editable node.
   * @return The root if it is editable and focused.
   */
  static getEditableRoot(node: AutomationNode): AutomationNode|undefined {
    let testNode: AutomationNode|undefined = node;
    let rootEditable;
    do {
      // TODO(b/267329383): testNode.state may be undefined.
      if (testNode.state!['editable'] && testNode.state!['focused']) {
        rootEditable = testNode;
      }
      testNode = testNode.parent;
    } while (testNode);
    return rootEditable;
  }

  /**
   * Gets the last (DFS) ordered node matched by a predicate assuming a
   * preference for ancestors.
   *
   * In detail:
   * Given a DFS ordering on nodes a_1, ..., a_n, applying a predicate
   * from 1 to n yields a different set of nodes from that when applying
   * a predicate from n to 1 if we skip the remaining descendants of a
   * successfully matched node when moving forward. To recover the same
   * nodes when applying the predicate from n to 1, we make the
   * observation that we want the shallowest node that matches the
   * predicate in a successfully matched node's ancestry chain.
   * Note that container nodes should only be considered if there are no current
   * matches.
   * @param root Tree to search.
   * @param pred A predicate to apply
   * @return The node found.
   */
  static findLastNode(root: AutomationNode, pred: AutomationPredicate.Unary):
      AutomationNode|null {
    let node: AutomationNode|null = root;
    while (node.lastChild) {
      node = node.lastChild;
    }

    do {
      if (AutomationPredicate.shouldIgnoreNode(node)) {
        continue;
      }

      // Get the shallowest node matching the predicate.
      let walker: AutomationNode|undefined = node;
      let shallowest = null;
      while (walker) {
        if (walker === root) {
          break;
        }

        if (pred(walker) && !AutomationPredicate.shouldIgnoreNode(walker) &&
            (!shallowest || !AutomationPredicate.container(walker))) {
          shallowest = walker;
        }

        walker = walker.parent;
      }

      if (shallowest) {
        return shallowest;
      }
    } while (
        node = AutomationUtil.findNextNode(node, constants.Dir.BACKWARD, pred));

    return null;
  }
}

/**
 * @param cur Node to begin the search from.
 * @param pred A predicate to apply to a candidate node.
 * @param optRestrictions |leaf|, |root|,
 *     |skipInitialAncestry|, and |skipInitialSubtree| are valid restrictions
 *     used when finding the next node.
 * @return Instance of tree walker initialized with given parameters.
 */
function createWalker(
    cur: AutomationNode, dir: constants.Dir, pred: AutomationPredicate.Unary,
    optRestrictions?: AutomationTreeWalkerRestriction): AutomationTreeWalker {
  const restrictions: AutomationTreeWalkerRestriction = {};
  optRestrictions = optRestrictions || {
    skipInitialSubtree: !AutomationPredicate.container(cur) && pred(cur),
  };

  restrictions.root = optRestrictions.root || AutomationPredicate.root;
  restrictions.leaf = optRestrictions.leaf || function(node) {
    // Treat nodes matched by |pred| as leaves except for containers.
    return !AutomationPredicate.container(node) && pred(node);
  };

  restrictions.skipInitialSubtree = optRestrictions.skipInitialSubtree;
  restrictions.skipInitialAncestry = optRestrictions.skipInitialAncestry;

  restrictions.visit = function(node: AutomationNode) {
    return pred(node) && !AutomationPredicate.shouldIgnoreNode(node);
  };

  return new AutomationTreeWalker(cur, dir, restrictions);
}

TestImportManager.exportForTesting(AutomationUtil);