Source: interaction.js

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const {utils: Cu} = Components;

Cu.import("chrome://marionette/content/accessibility.js");
Cu.import("chrome://marionette/content/atom.js");
const {
  error,
  InvalidArgument,
  ElementNotInteractableError,
  ElementClickInterceptedError,
  InvalidElementStateError,
  InvalidArgumentError,
} = Cu.import("chrome://marionette/content/error.js", {});
Cu.import("chrome://marionette/content/element.js");
Cu.import("chrome://marionette/content/event.js");

Cu.importGlobalProperties(["File"]);

this.EXPORTED_SYMBOLS = ["interaction"];

/** XUL elements that support disabled attribute. */
const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
  "ARROWSCROLLBOX",
  "BUTTON",
  "CHECKBOX",
  "COLORPICKER",
  "COMMAND",
  "DATEPICKER",
  "DESCRIPTION",
  "KEY",
  "KEYSET",
  "LABEL",
  "LISTBOX",
  "LISTCELL",
  "LISTHEAD",
  "LISTHEADER",
  "LISTITEM",
  "MENU",
  "MENUITEM",
  "MENULIST",
  "MENUSEPARATOR",
  "PREFERENCE",
  "RADIO",
  "RADIOGROUP",
  "RICHLISTBOX",
  "RICHLISTITEM",
  "SCALE",
  "TAB",
  "TABS",
  "TEXTBOX",
  "TIMEPICKER",
  "TOOLBARBUTTON",
  "TREE",
]);

/** XUL elements that support checked property. */
const CHECKED_PROPERTY_SUPPORTED_XUL = new Set([
  "BUTTON",
  "CHECKBOX",
  "LISTITEM",
  "TOOLBARBUTTON",
]);

/** XUL elements that support selected property. */
const SELECTED_PROPERTY_SUPPORTED_XUL = new Set([
  "LISTITEM",
  "MENU",
  "MENUITEM",
  "MENUSEPARATOR",
  "RADIO",
  "RICHLISTITEM",
  "TAB",
]);

/**
 * Common form controls that user can change the value property
 * interactively.
 */
const COMMON_FORM_CONTROLS = new Set([
  "input",
  "textarea",
  "select",
]);

/**
 * Input elements that do not fire <tt>input</tt> and <tt>change</tt>
 * events when value property changes.
 */
const INPUT_TYPES_NO_EVENT = new Set([
  "checkbox",
  "radio",
  "file",
  "hidden",
  "image",
  "reset",
  "button",
  "submit",
]);

/** @namespace */
this.interaction = {};

/**
 * Interact with an element by clicking it.
 *
 * The element is scrolled into view before visibility- or interactability
 * checks are performed.
 *
 * Selenium-style visibility checks will be performed
 * if <var>specCompat</var> is false (default).  Otherwise
 * pointer-interactability checks will be performed.  If either of these
 * fail an {@link ElementNotInteractableError} is thrown.
 *
 * If <var>strict</var> is enabled (defaults to disabled), further
 * accessibility checks will be performed, and these may result in an
 * {@link ElementNotAccessibleError} being returned.
 *
 * When <var>el</var> is not enabled, an {@link InvalidElementStateError}
 * is returned.
 *
 * @param {(DOMElement|XULElement)} el
 *     Element to click.
 * @param {boolean=} [strict=false] strict
 *     Enforce strict accessibility tests.
 * @param {boolean=} [specCompat=false] specCompat
 *     Use WebDriver specification compatible interactability definition.
 *
 * @throws {ElementNotInteractableError}
 *     If either Selenium-style visibility check or
 *     pointer-interactability check fails.
 * @throws {ElementClickInterceptedError}
 *     If <var>el</var> is obscured by another element and a click would
 *     not hit, in <var>specCompat</var> mode.
 * @throws {ElementNotAccessibleError}
 *     If <var>strict</var> is true and element is not accessible.
 * @throws {InvalidElementStateError}
 *     If <var>el</var> is not enabled.
 */
interaction.clickElement = function* (
    el, strict = false, specCompat = false) {
  const a11y = accessibility.get(strict);
  if (element.isXULElement(el)) {
    yield chromeClick(el, a11y);
  } else if (specCompat) {
    yield webdriverClickElement(el, a11y);
  } else {
    yield seleniumClickElement(el, a11y);
  }
};

function* webdriverClickElement(el, a11y) {
  const win = getWindow(el);

  // step 3
  if (el.localName == "input" && el.type == "file") {
    throw new InvalidArgumentError(
        "Cannot click <input type=file> elements");
  }

  let containerEl = element.getContainer(el);

  // step 4
  if (!element.isInView(containerEl)) {
    element.scrollIntoView(containerEl);
  }

  // step 5
  // TODO(ato): wait for containerEl to be in view

  // step 6
  // if we cannot bring the container element into the viewport
  // there is no point in checking if it is pointer-interactable
  if (!element.isInView(containerEl)) {
    throw new ElementNotInteractableError(
        error.pprint`Element ${el} could not be scrolled into view`);
  }

  // step 7
  let rects = containerEl.getClientRects();
  let clickPoint = element.getInViewCentrePoint(rects[0], win);

  if (element.isObscured(containerEl)) {
    throw new ElementClickInterceptedError(containerEl, clickPoint);
  }

  yield a11y.getAccessible(el, true).then(acc => {
    a11y.assertVisible(acc, el, true);
    a11y.assertEnabled(acc, el, true);
    a11y.assertActionable(acc, el);
  });

  // step 8
  if (el.localName == "option") {
    interaction.selectOption(el);
  } else {
    event.synthesizeMouseAtPoint(clickPoint.x, clickPoint.y, {}, win);
  }

  // step 9
  yield interaction.flushEventLoop(win);

  // step 10
  // if the click causes navigation, the post-navigation checks are
  // handled by the load listener in listener.js
}

function* chromeClick(el, a11y) {
  if (!atom.isElementEnabled(el)) {
    throw new InvalidElementStateError("Element is not enabled");
  }

  yield a11y.getAccessible(el, true).then(acc => {
    a11y.assertVisible(acc, el, true);
    a11y.assertEnabled(acc, el, true);
    a11y.assertActionable(acc, el);
  });

  if (el.localName == "option") {
    interaction.selectOption(el);
  } else {
    el.click();
  }
}

function* seleniumClickElement(el, a11y) {
  let win = getWindow(el);

  let visibilityCheckEl  = el;
  if (el.localName == "option") {
    visibilityCheckEl = element.getContainer(el);
  }

  if (!element.isVisible(visibilityCheckEl)) {
    throw new ElementNotInteractableError();
  }

  if (!atom.isElementEnabled(el)) {
    throw new InvalidElementStateError("Element is not enabled");
  }

  yield a11y.getAccessible(el, true).then(acc => {
    a11y.assertVisible(acc, el, true);
    a11y.assertEnabled(acc, el, true);
    a11y.assertActionable(acc, el);
  });

  if (el.localName == "option") {
    interaction.selectOption(el);
  } else {
    let rects = el.getClientRects();
    let centre = element.getInViewCentrePoint(rects[0], win);
    let opts = {};
    event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
  }
}

/**
 * Select <tt>&lt;option&gt;</tt> element in a <tt>&lt;select&gt;</tt>
 * list.
 *
 * Because the dropdown list of select elements are implemented using
 * native widget technology, our trusted synthesised events are not able
 * to reach them.  Dropdowns are instead handled mimicking DOM events,
 * which for obvious reasons is not ideal, but at the current point in
 * time considered to be good enough.
 *
 * @param {HTMLOptionElement} option
 *     Option element to select.
 *
 * @throws {TypeError}
 *     If <var>el</var> is a XUL element or not an <tt>&lt;option&gt;</tt>
 *     element.
 * @throws {Error}
 *     If unable to find <var>el</var>'s parent <tt>&lt;select&gt;</tt>
 *     element.
 */
interaction.selectOption = function(el) {
  if (element.isXULElement(el)) {
    throw new Error("XUL dropdowns not supported");
  }
  if (el.localName != "option") {
    throw new TypeError("Invalid elements");
  }

  let containerEl = element.getContainer(el);

  event.mouseover(containerEl);
  event.mousemove(containerEl);
  event.mousedown(containerEl);
  event.focus(containerEl);
  event.input(containerEl);

  // toggle selectedness the way holding down control works
  el.selected = !el.selected;

  event.change(containerEl);
  event.mouseup(containerEl);
  event.click(containerEl);
};

/**
 * Flushes the event loop by requesting an animation frame.
 *
 * This will wait for the browser to repaint before returning, typically
 * flushing any queued events.
 *
 * If the document is unloaded during this request, the promise is
 * rejected.
 *
 * @param {Window} win
 *     Associated window.
 *
 * @return {Promise}
 *     Promise is accepted once event queue is flushed, or rejected if
 *     <var>win</var> has closed or been unloaded before the queue can
 *     be flushed.
 */
interaction.flushEventLoop = function* (win) {
  return new Promise(resolve => {
    let handleEvent = event => {
      win.removeEventListener("beforeunload", this);
      resolve();
    };

    if (win.closed) {
      resolve();
      return;
    }

    win.addEventListener("beforeunload", handleEvent);
    win.requestAnimationFrame(handleEvent);
  });
};

/**
 * Appends <var>path</var> to an <tt>&lt;input type=file&gt;</tt>'s
 * file list.
 *
 * @param {HTMLInputElement} el
 *     An <tt>&lt;input type=file&gt;</tt> element.
 * @param {string} path
 *     Full path to file.
 */
interaction.uploadFile = function* (el, path) {
  let file = yield File.createFromFileName(path).then(file => {
    return file;
  }, () => {
    return null;
  });

  if (!file) {
    throw new InvalidArgumentError("File not found: " + path);
  }

  let fs = Array.prototype.slice.call(el.files);
  fs.push(file);

  // <input type=file> opens OS widget dialogue
  // which means the mousedown/focus/mouseup/click events
  // occur before the change event
  event.mouseover(el);
  event.mousemove(el);
  event.mousedown(el);
  event.focus(el);
  event.mouseup(el);
  event.click(el);

  el.mozSetFileArray(fs);

  event.change(el);
};

/**
 * Sets a form element's value.
 *
 * @param {DOMElement} el
 *     An form element, e.g. input, textarea, etc.
 * @param {string} value
 *     The value to be set.
 *
 * @throws {TypeError}
 *     If <var>el</var> is not an supported form element.
 */
interaction.setFormControlValue = function* (el, value) {
  if (!COMMON_FORM_CONTROLS.has(el.localName)) {
    throw new TypeError("This function is for form elements only");
  }

  el.value = value;

  if (INPUT_TYPES_NO_EVENT.has(el.type)) {
    return;
  }

  event.input(el);
  event.change(el);
};

/**
 * Send keys to element.
 *
 * @param {DOMElement|XULElement} el
 *     Element to send key events to.
 * @param {Array.<string>} value
 *     Sequence of keystrokes to send to the element.
 * @param {boolean} ignoreVisibility
 *     Flag to enable or disable element visibility tests.
 * @param {boolean=} [strict=false] strict
 *     Enforce strict accessibility tests.
 */
interaction.sendKeysToElement = function(
    el, value, ignoreVisibility, strict = false) {
  let win = getWindow(el);
  let a11y = accessibility.get(strict);
  return a11y.getAccessible(el, true).then(acc => {
    a11y.assertActionable(acc, el);
    event.sendKeysToElement(value, el, {ignoreVisibility: false}, win);
  });
};

/**
 * Determine the element displayedness of an element.
 *
 * @param {DOMElement|XULElement} el
 *     Element to determine displayedness of.
 * @param {boolean=} [strict=false] strict
 *     Enforce strict accessibility tests.
 *
 * @return {boolean}
 *     True if element is displayed, false otherwise.
 */
interaction.isElementDisplayed = function(el, strict = false) {
  let win = getWindow(el);
  let displayed = atom.isElementDisplayed(el, win);

  let a11y = accessibility.get(strict);
  return a11y.getAccessible(el).then(acc => {
    a11y.assertVisible(acc, el, displayed);
    return displayed;
  });
};

/**
 * Check if element is enabled.
 *
 * @param {DOMElement|XULElement} el
 *     Element to test if is enabled.
 *
 * @return {boolean}
 *     True if enabled, false otherwise.
 */
interaction.isElementEnabled = function(el, strict = false) {
  let enabled = true;
  let win = getWindow(el);

  if (element.isXULElement(el)) {
    // check if XUL element supports disabled attribute
    if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) {
      let disabled = atom.getElementAttribute(el, "disabled", win);
      if (disabled && disabled === "true") {
        enabled = false;
      }
    }
  } else {
    enabled = atom.isElementEnabled(el, {frame: win});
  }

  let a11y = accessibility.get(strict);
  return a11y.getAccessible(el).then(acc => {
    a11y.assertEnabled(acc, el, enabled);
    return enabled;
  });
};

/**
 * Determines if the referenced element is selected or not.
 *
 * This operation only makes sense on input elements of the Checkbox-
 * and Radio Button states, or option elements.
 *
 * @param {DOMElement|XULElement} el
 *     Element to test if is selected.
 * @param {boolean=} [strict=false] strict
 *     Enforce strict accessibility tests.
 *
 * @return {boolean}
 *     True if element is selected, false otherwise.
 */
interaction.isElementSelected = function(el, strict = false) {
  let selected = true;
  let win = getWindow(el);

  if (element.isXULElement(el)) {
    let tagName = el.tagName.toUpperCase();
    if (CHECKED_PROPERTY_SUPPORTED_XUL.has(tagName)) {
      selected = el.checked;
    }
    if (SELECTED_PROPERTY_SUPPORTED_XUL.has(tagName)) {
      selected = el.selected;
    }
  } else {
    selected = atom.isElementSelected(el, win);
  }

  let a11y = accessibility.get(strict);
  return a11y.getAccessible(el).then(acc => {
    a11y.assertSelected(acc, el, selected);
    return selected;
  });
};

function getWindow(el) {
  return el.ownerGlobal;
}