chromium/components/test/data/password_manager/form_annotation_extension/content.js

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

/**
 * Find one or two CSS classes in the class list |list| that uniquely identify
 * the element in the document |doc|.
 * If the two classes are not enough to uniquely identify the element, chooses
 * two classes that minimize the number of elements (that have these classes).
 *
 * @param {string} list The class list of the element to be uniquely identified.
 * @param {Document} doc The owner document of the element.
 * @return {string} One or two class names joined with a space.
 */
function reduceClassName(list, doc) {
  let minCount = 1000000;
  let minCountClasses = '';
  for (i = 0; i < list.length; i++) {
    const className = list.item(i);
    const count = doc.getElementsByClassName(className).length;
    if (count == 1) {
      return '.' + className;
    }
    if (count < minCount) {
      minCount = count;
      minCountClasses = '.' + className;
    }
  }
  for (i = 0; i < list.length; i++) {
    const className1 = list.item(i);
    for (j = 0; j < list.length; j++) {
      const className2 = list.item(j);
      const count =
          doc.getElementsByClassName(className1 + ' ' + className2).length;
      if (count == 1) {
        return '.' + className1 + '.' + className2;
      }
      if (count < minCount) {
        minCount = count;
        minCountClasses = '.' + className1 + '.' + className2;
      }
    }
  }
  return minCountClasses;
}

/**
 * For the given element |elem|, returns the number of previous siblings that
 * have LI tag.
 *
 * @param {Element} elem The element siblings of which should be counted.
 * @return {number} The number of siblings with LI tag.
 */
function getIndexInChildrenList(elem) {
  let result = 1;
  let sibling = elem.previousSibling;
  while (sibling) {
    if (sibling.tagName == 'LI') {
      result++;
    }
    sibling = sibling.previousSibling;
  }
  return result;
}

/**
 * Returns compact CSS selector that uniquely identifies |elem|.
 * MOST IMPORTANT part in the extension. By a number of heuristics, it creates
 * a CSS selector that would be persistent to web page changes (e.g. full path
 * to the element |elem| is a bad idea), but allows to find element |elem|.
 * TODO(crbug.com/41255533): sometimes it fails to build unique selector. Fix it.
 *
 * @param {Element} elem The element which CSS selector should be created.
 * @return {string} CSS selector of the element.
 */
function getSmartSelector(elem) {
  const doc = elem.ownerDocument;
  let result = elem.tagName;

  if (elem.id) {
    result += '[id=\'' + elem.id + '\']';
  }  // Works for IDs started with a digit.
  if (elem.name) {
    result += '[name=\'' + elem.name + '\']';
  }
  if (elem.tagName == 'INPUT' && elem.type) {
    result += '[type=\'' + elem.type + '\']';
  }
  if (elem.classList.length > 0) {
    result += reduceClassName(elem.classList, doc);
  }
  if (elem.tagName == 'LI') {
    result += ':nth-child(' + getIndexInChildrenList(elem) + ')';
  }

  // If failed to build a unique selector for |elem|, try to add the parent CSS
  // selector.
  if (doc.querySelectorAll(result).length != 1) {
    if (elem.parentElement) {
      const parentSelector = getSmartSelector(elem.parentElement);
      if (parentSelector) {
        return parentSelector + ' > ' + result;
      }
    }
    console.error('failed to build unique css selector ' + result + ': ' +
        doc.querySelectorAll(result));
    return '';
  } else {
    return result;
  }
}

/**
 * Returns CSS selectors of parent frames.
 * Doesn't work for cross-domain iframes. TODO(crbug.com/41255536): Chrome
 * extensions should be able to process cross-domain stuff. Fix it.
 *
 * @param {Element} elem The element which parent frames should be returned.
 * @return {Array} The array of CSS selectors of parent frames.
 */
function getFrames(elem) {
  frames = [];
  while (elem.ownerDocument.defaultView != top) {
    const frameElement = elem.ownerDocument.defaultView.frameElement;
    if (!frameElement) {
      console.error('frameElement is null. Unable to fetch data about iframes');
      break;
    }
    const iframeSelector = getSmartSelector(frameElement);
    frames.unshift(iframeSelector);
    elem = elem.ownerDocument.defaultView.frameElement;
  }
  return frames;
}

/**
 * Returns true if |element| is probably a clickable element.
 *
 * @param {Element} element The element to be checked.
 * @return {boolean} True if the element is probably clickable.
 */
function isClickableElementOrInput(element) {
  return (element.tagName == 'INPUT' || element.tagName == 'A' ||
      element.tagName == 'BUTTON' || element.tagName == 'SUBMIT' ||
      element.getAttribute('href'));
}

/**
 * Returns |element|, if |element| is clickable element. Othrewise, returns
 * clickable children or parent of the given element |element|.
 * Font element might consume a user click, but Chrome Driver will be unable to
 * click on the font element, so find really clickable element to perform click.
 *
 * @param {Element} element The element where a clickable tag should be find.
 * @return {Element} The clicable element.
 */
function fixElementSelection(element) {
  if (isClickableElementOrInput(element)) {
    return element;
  }
  const clickableChildren = element.querySelectorAll(
      ':scope input, :scope a, :scope button, :scope submit, :scope [href]');
  if (clickableChildren.length > 0) {
    return clickableChildren[0];
  }
  let parent = element;
  for (let i = 0; i < 5; i++) {
    parent = parent.parentElement;
    if (!parent) {
      break;
    }
    if (isClickableElementOrInput(parent)) {
      return parent;
    }
  }
  return element;
}

/**
 * Check if it is possible to fetch the owner document of |elem| and find |elem|
 * there.
 * Sometimes getting to the form require a number of steps. So, keeping just URL
 * of the last step is not enough. This function checks if it is possible to
 * start with the |elem.ownerDocument.URL|.
 *
 * @param {Element} elem The element to be checked.
 * @param {string} selector The CSS selector of |elem|.
 * @return {boolean} True, if it is possible find an element with CSS selector
 *    |selector| on |elem.ownerDocument.URL|.
 */
function couldBeFirstStepOfScript(elem, selector) {
  try {
    const xmlHttp = new XMLHttpRequest();
    xmlHttp.open('GET', elem.ownerDocument.URL,
                 false /* false for synchronous request */);
    xmlHttp.send();
    const wrapper = document.createElement('html');
    wrapper.innerHTML = xmlHttp.responseText;
    const e = wrapper.querySelector(selector);
    return e && (e.offsetWidth * e.offsetHeight > 0);
  } catch (err) {
    return false;
  }
}

/**
 * Add click listener.
 */
document.addEventListener('click', function(event) {
  const element = fixElementSelection(event.target);
  const url = element.ownerDocument.URL;
  const isPwdField = (element.tagName == 'INPUT') && (element.type == 'password');
  const selector = getSmartSelector(element);
  const frames = getFrames(element);
  const classifierOutcome = element.hasAttribute('pm_debug_pwd_creation_field');
  const couldBeFirst = couldBeFirstStepOfScript(element, selector);
  chrome.runtime.sendMessage(
    {isPwdField: isPwdField, selector: selector, url: url, frames: frames,
     couldBeFirst: couldBeFirst, classifierOutcome: classifierOutcome},
    function(response) {});
}, true);