// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// dom_automation.js
// Methods for performing common DOM operations. Used in Chrome testing
// involving the DomAutomationController.

var domAutomation = domAutomation || {};

(function() {
  // |objects| is used to track objects which are sent back to the
  // DomAutomationController. Since JavaScript does not have a map type,
  // |objects| is simply an object in which the property name and
  // property value serve as the key-value pair. The key is the handle number
  // and the value is the tracked object.
  domAutomation.objects = {};

  // The next object handle to use.
  domAutomation.nextHandle = 1;

  // The current call ID for which a response is awaited. Each asynchronous
  // function is given a call ID. When the function has a result to return,
  // it must supply that call ID. If a result has not yet been received for
  // that call ID, a response containing the result will be sent to the
  // domAutomationController.
  domAutomation.currentCallId = 1;

  // The current timeout for an asynchronous JavaScript evaluation. Can be given
  // to window.clearTimeout.
  domAutomation.currentTimeout = null;

  // Returns |value| after converting it to an acceptable type for return, if
  // necessary.
  function getConvertedValue(value) {
    if (typeof value == "undefined" || !value) {
      return "";
    }
    if (value instanceof Array) {
      var result = [];
      for (var i = 0; i < value.length; i++) {
        result.push(getConvertedValue(value[i]));
      }
      return result;
    }
    if (typeof(value) == "object") {
      var handle = getHandleForObject(value);
      if (handle == -1) {
        // Track this object.
        var handle = domAutomation.nextHandle++;
        domAutomation.objects[handle] = value;
      }
      return handle;
    }
    return value;
  }

  // Returns the handle for |obj|, or -1 if no handle exists.
  function getHandleForObject(obj) {
      for (var property in domAutomation.objects) {
        if (domAutomation.objects[property] == obj)
          return parseInt(property);
      }
      return -1;
  }

  // Sends a completed response back to the domAutomationController with a
  // return value, which can be of any type.
  function sendCompletedResponse(returnValue) {
    var result = [true, "", getConvertedValue(returnValue)];
    domAutomationController.sendJSON(JSON.stringify(result));
  }

  // Sends a error response back to the domAutomationController. |exception|
  // should be a string or an exception.
  function sendErrorResponse(exception) {
    var message = exception.message;
    if (typeof message == "undefined")
      message = exception;
    if (typeof message != "string")
      message = JSON.stringify(message);
    var result = [false, message, exception];
    domAutomationController.sendJSON(JSON.stringify(result));
  }

  // Safely evaluates |javascript| and sends a response back via the
  // DomAutomationController. See javascript_execution_controller.cc
  // for more details.
  domAutomation.evaluateJavaScript = function(javascript) {
    try {
      sendCompletedResponse(eval(javascript));
    }
    catch (exception) {
      sendErrorResponse(exception);
    }
  }

  // Called by a function when it has completed successfully. Any value,
  // including undefined, is acceptable for |returnValue|. This should only
  // be used by functions with an asynchronous response.
  function onAsyncJavaScriptComplete(callId, returnValue) {
    if (domAutomation.currentCallId != callId) {
      // We are not waiting for a response for this call anymore,
      // because it already responded.
      return;
    }
    domAutomation.currentCallId++;
    window.clearTimeout(domAutomation.currentTimeout);
    sendCompletedResponse(returnValue);
  }

  // Calld by a function when it has an error preventing its successful
  // execution. |exception| should be an exception or a string.
  function onAsyncJavaScriptError(callId, exception) {
    if (domAutomation.currentCallId != callId) {
      // We are not waiting for a response for this call anymore,
      // because it already responded.
      return;
    }
    domAutomation.currentCallId++;
    window.clearTimeout(domAutomation.currentTimeout);
    sendErrorResponse(exception);
  }

  // Returns whether the call with the given ID has already finished. If true,
  // this means that the call timed out or that it already gave a response.
  function didCallFinish(callId) {
    return domAutomation.currentCallId != callId;
  }

  // Safely evaluates |javascript|. The JavaScript is expected to return
  // a response via either onAsyncJavaScriptComplete or
  // onAsyncJavaScriptError. The script should respond within the |timeoutMs|.
  domAutomation.evaluateAsyncJavaScript = function(javascript, timeoutMs) {
    try {
      eval(javascript);
    }
    catch (exception) {
      onAsyncJavaScriptError(domAutomation.currentCallId, exception);
      return;
    }
    domAutomation.currentTimeout = window.setTimeout(
        onAsyncJavaScriptError, timeoutMs, domAutomation.currentCallId,
        "JavaScript timed out waiting for response.");
  }

  // Stops tracking the object associated with |handle|.
  domAutomation.removeObject = function(handle) {
    delete domAutomation.objects[handle];
  }

  // Stops tracking all objects.
  domAutomation.removeAll = function() {
    domAutomation.objects = {};
    domAutomation.nextHandle = 1;
  }

  // Gets the object associated with this |handle|.
  domAutomation.getObject = function(handle) {
    var obj = domAutomation.objects[handle]
    if (typeof obj == "undefined") {
      throw "Object with handle " + handle + " does not exist."
    }
    return domAutomation.objects[handle];
  }

  // Gets the ID for this asynchronous call.
  domAutomation.getCallId = function() {
    return domAutomation.currentCallId;
  }

  // Converts an indexable list with a length property to an array.
  function getArray(list) {
    var arr = [];
    for (var i = 0; i < list.length; i++) {
      arr.push(list[i]);
    }
    return arr;
  }

  // Removes whitespace at the beginning and end of |text|.
  function trim(text) {
    return text.replace(/^\s+|\s+$/g, "");
  }

  // Returns the window (which is a sub window of |win|) which
  // directly contains |doc|. May return null.
  function findWindowForDocument(win, doc) {
    if (win.document == doc)
      return win;
    for (var i = 0; i < win.frames.length; i++) {
      if (findWindowForDocument(win.frames[i], doc))
        return win.frames[i];
    }
    return null;
  }

  // Returns |element|'s text. This includes all descendants' text.
  // For textareas and inputs, the text is the element's value. For Text,
  // it is the textContent.
  function getText(element) {
    if (element instanceof Text) {
      return trim(element.textContent);
    } else if (element instanceof HTMLTextAreaElement ||
               element instanceof HTMLInputElement) {
      return element.value || "";
    }
    var childrenText = "";
    for (var i = 0; i < element.childNodes.length; i++) {
      childrenText += getText(element.childNodes[i]);
    }
    return childrenText;
  }

  // Returns whether |element| is visible.
  function isVisible(element) {
    while (element.style) {
      if (element.style.display == 'none' ||
          element.style.visibility == 'hidden' ||
          element.style.visibility == 'collapse') {
        return false;
      }
      element = element.parentNode;
    }
    return true;
  }

  // Returns an array of the visible elements found in the |elements| array.
  function getVisibleElements(elements) {
    var visibleElements = [];
    for (var i = 0; i < elements.length; i++) {
      if (isVisible(elements[i]))
        visibleElements.push(elements[i]);
    }
    return visibleElements;
  }

  // Finds all the elements which satisfy the xpath query using the context
  // node |context|. This function may throw an exception.
  function findByXPath(context, xpath) {
    var xpathResult =
        document.evaluate(xpath, context, null,
                          XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    var elements = [];
    for (var i = 0; i < xpathResult.snapshotLength; i++) {
      elements.push(xpathResult.snapshotItem(i));
    }
    return elements;
  }

  // Finds the first element which satisfies the xpath query using the context
  // node |context|. This function may throw an exception.
  function find1ByXPath(context, xpath) {
    var xpathResult =
        document.evaluate(xpath, context, null,
                          XPathResult.FIRST_ORDERED_NODE_TYPE, null);
    return xpathResult.singleNodeValue;
  }

  // Finds all the elements which satisfy the selectors query using the context
  // node |context|. This function may throw an exception.
  function findBySelectors(context, selectors) {
    return getArray(context.querySelectorAll(selectors));
  }

  // Finds the first element which satisfies the selectors query using the
  // context node |context|. This function may throw an exception.
  function find1BySelectors(context, selectors) {
    return context.querySelector(selectors);
  }

  // Finds all the elements which contain |text| using the context
  // node |context|. See getText for details about what constitutes the text
  // of an element. This function may throw an exception.
  function findByText(context, text) {
    // Find all elements containing this text and all inputs containing
    // this text.
    var xpath = ".//*[contains(text(), '" + text + "')] | " +
                ".//input[contains(@value, '" + text + "')]";
    var elements = findByXPath(context, xpath);

    // Limit to what is visible.
    return getVisibleElements(elements);
  }

  // Finds the first element which contains |text| using the context
  // node |context|. See getText for details about what constitutes the text
  // of an element. This function may throw an exception.
  function find1ByText(context, text) {
    var elements = findByText(context, text);
    if (elements.length > 0)
      return findByText(context, text)[0];
    return null;
  }

  //// DOM Element automation methods
  //// See dom_element_proxy.h for method details.

  domAutomation.getDocumentFromFrame = function(element, frameNames) {
    // Find the window this element is in.
    var containingDocument = element.ownerDocument || element;
    var frame = findWindowForDocument(window, containingDocument);

    for (var i = 0; i < frameNames.length; i++) {
      frame = frame.frames[frameNames[i]];
      if (typeof frame == "undefined" || !frame) {
        return null;
      }
    }
    return frame.document;
  }

  domAutomation.findElement = function(context, query) {
    var type = query.type;
    var queryString = query.queryString;
    if (type == "xpath") {
      return find1ByXPath(context, queryString);
    } else if (type == "selectors") {
      return find1BySelectors(context, queryString);
    } else if (type == "text") {
      return find1ByText(context, queryString);
    }
  }

  domAutomation.findElements = function(context, query) {
    var type = query.type;
    var queryString = query.queryString;
    if (type == "xpath") {
      return findByXPath(context, queryString);
    } else if (type == "selectors") {
      return findBySelectors(context, queryString);
    } else if (type == "text") {
      return findByText(context, queryString);
    }
  }

  domAutomation.waitForVisibleElementCount = function(context, query, count,
                                                      callId) {
    (function waitHelper() {
      try {
        var elements = domAutomation.findElements(context, query);
        var visibleElements = getVisibleElements(elements);
        if (visibleElements.length == count)
          onAsyncJavaScriptComplete(callId, visibleElements);
        else if (!didCallFinish(callId))
          window.setTimeout(waitHelper, 500);
      }
      catch (exception) {
        onAsyncJavaScriptError(callId, exception);
      }
    })();
  }

  domAutomation.click = function(element) {
    var evt = document.createEvent('MouseEvents');
    evt.initMouseEvent('click', true, true, window,
                       0, 0, 0, 0, 0, false, false,
                       false, false, 0, null);
    while (element) {
      element.dispatchEvent(evt);
      element = element.parentNode;
    }
  }

  domAutomation.type = function(element, text) {
    if (element instanceof HTMLTextAreaElement ||
        (element instanceof HTMLInputElement && element.type == "text")) {
      element.value += text;
      return true;
    }
    return false;
  }

  domAutomation.setText = function(element, text) {
    if (element instanceof HTMLTextAreaElement ||
        (element instanceof HTMLInputElement && element.type == "text")) {
      element.value = text;
      return true;
    }
    return false;
  }

  domAutomation.getProperty = function(element, property) {
    return element[property];
  }

  domAutomation.getAttribute = function(element, attribute) {
    return element.getAttribute(attribute);
  }

  domAutomation.getValue = function(element, type) {
    if (type == "text") {
      return getText(element);
    } else if (type == "innerhtml") {
      return trim(element.innerHTML);
    } else if (type == "innertext") {
      return trim(element.innerText);
    } else if (type == "visibility") {
      return isVisible(element);
    } else if (type == "id") {
      return element.id;
    } else if (type == "contentdocument") {
      return element.contentDocument;
    }
  }

  domAutomation.waitForAttribute = function(element, attribute, value, callId) {
    (function waitForAttributeHelper() {
      try {
        if (element.getAttribute(attribute) == value)
          onAsyncJavaScriptComplete(callId);
        else if (!didCallFinish(callId))
          window.setTimeout(waitForAttributeHelper, 200);
      }
      catch (exception) {
        onAsyncJavaScriptError(callId, exception);
      }
    })();
  }
})();