Add infrastructure for Interactive tests

Inspired by the SemiAuto tests used by the test team, this uses a
similar approach but wrapped in an XML-RPC server deployed to the
client.

BUG=chromium:256771
TEST=server test that uses interactive_client

Change-Id: I8420a2a2ed6fc031b97044d6bbcf32e50cbcf4a4
Reviewed-on: https://gerrit.chromium.org/gerrit/63640
Reviewed-by: Simran Basi <sbasi@chromium.org>
Commit-Queue: Scott James Remnant <keybuk@chromium.org>
Tested-by: Scott James Remnant <keybuk@chromium.org>
diff --git a/client/common_lib/cros/bluetooth/bluetooth_client_xmlrpc_server.py b/client/common_lib/cros/bluetooth/bluetooth_client_xmlrpc_server.py
index af03270..a9b687a 100755
--- a/client/common_lib/cros/bluetooth/bluetooth_client_xmlrpc_server.py
+++ b/client/common_lib/cros/bluetooth/bluetooth_client_xmlrpc_server.py
@@ -11,17 +11,17 @@
 import shutil
 
 import common
+from autotest_lib.client.bin import utils
 from autotest_lib.client.common_lib.cros import xmlrpc_server
 from autotest_lib.client.cros import constants
-from telemetry.core import util
 
 
 class BluetoothClientXmlRpcDelegate(xmlrpc_server.XmlRpcDelegate):
-    """Exposes DUT methods called removely during Bluetooth autotests.
+    """Exposes DUT methods called remotely during Bluetooth autotests.
 
     All instance methods of this object without a preceding '_' are exposed via
     an XML-RPC server. This is not a stateless handler object, which means that
-    if you store state inside the delegate, that state will remain aroun dfor
+    if you store state inside the delegate, that state will remain around for
     future calls.
     """
 
@@ -71,26 +71,39 @@
         self._update_bluez()
         self._update_adapter()
 
+
     def _update_bluez(self):
-        """Store a D-Bus proxy for the Bluetooth daemon in self._bluez."""
+        """Store a D-Bus proxy for the Bluetooth daemon in self._bluez.
+
+        @return True on success, False otherwise.
+
+        """
         self._bluez = None
         try:
             self._bluez = self._system_bus.get_object(
                     self.BLUEZ_SERVICE_NAME,
                     self.BLUEZ_MANAGER_PATH)
             logging.debug('bluetoothd is running')
+            return True
         except dbus.exceptions.DBusException, e:
             if e.get_dbus_name() == self.DBUS_ERROR_SERVICEUNKNOWN:
                 logging.debug('bluetoothd is not running')
                 self._bluez = None
+                return False
             else:
                 raise
 
+
     def _update_adapter(self):
-        """Store a D-Bus proxy for the local adapter in self._adapter."""
+        """Store a D-Bus proxy for the local adapter in self._adapter.
+
+        @return True on success, including if there is no local adapter,
+            False otherwise.
+
+        """
         self._adapter = None
         if self._bluez is None:
-            return
+            return False
 
         objects = self._bluez.GetManagedObjects(
                 dbus_interface=self.BLUEZ_MANAGER_IFACE)
@@ -101,7 +114,10 @@
                 self._adapter = self._system_bus.get_object(
                         self.BLUEZ_SERVICE_NAME,
                         path)
-                break
+                return True
+        else:
+            return False
+
 
     @xmlrpc_server.dbus_safe(False)
     def reset_on(self):
@@ -114,6 +130,7 @@
         self._set_powered(True)
         return True
 
+
     @xmlrpc_server.dbus_safe(False)
     def reset_off(self):
         """Reset the adapter and settings, leave the adapter powered off.
@@ -124,6 +141,7 @@
         self._reset()
         return True
 
+
     def _reset(self):
         """Reset the Bluetooth adapter and settings."""
         logging.debug('_reset')
@@ -143,23 +161,26 @@
         self._bluetoothd.Start(dbus.Array(signature='s'), True,
                                dbus_interface=self.UPSTART_JOB_IFACE)
 
-        # We can't just pass self._update_bluez/adapter to util.WaitFor because
-        # it wont take instance functions, so wrap.
+        # We can't just pass self._update_bluez/adapter to poll_for_condition
+        # because we need to check the local state.
         logging.debug('waiting for bluez start')
-        def start_bluez():
-            self._update_bluez()
-            return self._bluez is not None
-        def get_adapter():
-            self._update_adapter()
-            return self._adapter is not None
-        util.WaitFor(start_bluez, self.ADAPTER_TIMEOUT)
-        util.WaitFor(get_adapter, self.ADAPTER_TIMEOUT)
+        utils.poll_for_condition(
+                condition=self._update_bluez,
+                desc='Bluetooth Daemon has started.',
+                timeout=self.ADAPTER_TIMEOUT)
+
+        logging.debug('waiting for bluez to obtain adapter information')
+        utils.poll_for_condition(
+                condition=self._update_adapter,
+                desc='Bluetooth Daemon has adapter information.',
+                timeout=self.ADAPTER_TIMEOUT)
+
 
     @xmlrpc_server.dbus_safe(False)
     def set_powered(self, powered):
         """Set the adapter power state.
 
-        @param powered adapter power state to set (True or False).
+        @param powered: adapter power state to set (True or False).
 
         @return True on success, False otherwise.
 
@@ -167,21 +188,23 @@
         self._set_powered(powered)
         return True
 
+
     def _set_powered(self, powered):
         """Set the adapter power state.
 
-        @param powered adapter power state to set (True or False).
+        @param powered: adapter power state to set (True or False).
 
         """
         logging.debug('_set_powered %r', powered)
         self._adapter.Set(self.BLUEZ_ADAPTER_IFACE, 'Powered', powered,
                           dbus_interface=dbus.PROPERTIES_IFACE)
 
+
     @xmlrpc_server.dbus_safe(False)
     def set_discoverable(self, discoverable):
         """Set the adapter discoverable state.
 
-        @param powered adapter discoverable state to set (True or False).
+        @param discoverable: adapter discoverable state to set (True or False).
 
         @return True on success, False otherwise.
 
@@ -191,11 +214,12 @@
                           dbus_interface=dbus.PROPERTIES_IFACE)
         return True
 
+
     @xmlrpc_server.dbus_safe(False)
     def set_pairable(self, pairable):
         """Set the adapter pairable state.
 
-        @param powered adapter pairable state to set (True or False).
+        @param pairable: adapter pairable state to set (True or False).
 
         @return True on success, False otherwise.
 
@@ -207,7 +231,7 @@
 
 if __name__ == '__main__':
     logging.basicConfig(level=logging.DEBUG)
-    handler = logging.handlers.SysLogHandler(address = '/dev/log')
+    handler = logging.handlers.SysLogHandler(address='/dev/log')
     logging.getLogger().addHandler(handler)
     logging.debug('bluetooth_client_xmlrpc_server main...')
     server = xmlrpc_server.XmlRpcServer(
diff --git a/client/common_lib/cros/interactive_xmlrpc_server.py b/client/common_lib/cros/interactive_xmlrpc_server.py
new file mode 100755
index 0000000..ec696f6
--- /dev/null
+++ b/client/common_lib/cros/interactive_xmlrpc_server.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import cgi
+import json
+import logging
+import logging.handlers
+import os
+import sys
+
+import common
+from autotest_lib.client.bin import utils
+from autotest_lib.client.common_lib.cros import chrome, xmlrpc_server
+from autotest_lib.client.cros import constants
+
+
+class InteractiveXmlRpcDelegate(xmlrpc_server.XmlRpcDelegate):
+    """Exposes methods called remotely to create interactive tests.
+
+    All instance methods of this object without a preceding '_' are exposed via
+    an XML-RPC server. This is not a stateless handler object, which means that
+    if you store state inside the delegate, that state will remain around for
+    future calls.
+    """
+
+    def login(self):
+        """Login to the system and open a tab.
+
+        The tab opened is used by other methods on this server to interact
+        with the user.
+
+        @return True.
+
+        """
+        self._chrome = chrome.Chrome()
+        self._chrome.browser.SetHTTPServerDirectories(
+                os.path.dirname(sys.argv[0]))
+        self._tab = self._chrome.browser.tabs[0]
+        self._tab.Navigate(self._chrome.browser.http_server.UrlOf('shell.html'))
+
+        return True
+
+
+    def set_output(self, html):
+        """Replace the contents of the tab.
+
+        @param html: HTML document to replace tab contents with.
+
+        @return True.
+
+        """
+        # JSON does a better job of escaping HTML for JavaScript than we could
+        # with string.replace().
+        html_escaped = json.dumps(html)
+        # Use JavaScript to append the output and scroll to the bottom of the
+        # open tab.
+        self._tab.ExecuteJavaScript('document.body.innerHTML = %s; ' %
+                                    html_escaped)
+        self._tab.Activate()
+        self._tab.WaitForDocumentReadyStateToBeInteractiveOrBetter()
+        return True
+
+
+    def append_output(self, html):
+        """Append HTML to the contents of the tab.
+
+        @param html: HTML to append to the existing tab contents.
+
+        @return True.
+
+        """
+        # JSON does a better job of escaping HTML for JavaScript than we could
+        # with string.replace().
+        html_escaped = json.dumps(html)
+        # Use JavaScript to append the output and scroll to the bottom of the
+        # open tab.
+        self._tab.ExecuteJavaScript(
+                ('document.body.innerHTML += %s; ' % html_escaped) +
+                'window.scrollTo(0, document.body.scrollHeight);')
+        self._tab.Activate()
+        self._tab.WaitForDocumentReadyStateToBeInteractiveOrBetter()
+        return True
+
+
+    def append_buttons(self, *args):
+        """Append confirmation buttons to the tab.
+
+        Each button is given an index, 0 for the first button, 1 for the second,
+        and so on.
+
+        @param title...: Title of button to append.
+
+        @return True.
+
+        """
+        html = ''
+        index = 0
+        for title in args:
+            onclick = 'submit_button(%d)' % index
+            html += ('<input type="button" value="%s" onclick="%s">' % (
+                     cgi.escape(title),
+                     cgi.escape(onclick)))
+            index += 1
+        return self.append_output(html)
+
+
+    def wait_for_button(self, timeout):
+        """Wait for a button to be clicked.
+
+        Call append_buttons() before this to add buttons to the document.
+
+        @param timeout: Maximum time, in seconds, to wait for a click.
+
+        @return index of button that was clicked.
+
+        """
+        # Wait for the button to be clicked.
+        utils.poll_for_condition(
+                condition=lambda:
+                    self._tab.EvaluateJavaScript('window.__ready') == 1,
+                desc='User clicked on button.',
+                timeout=timeout)
+        # Fetch the result.
+        result = self._tab.EvaluateJavaScript('window.__result')
+        # Reset for the next button.
+        self._tab.ExecuteJavaScript(
+                'window.__ready = 0; '
+                'window.__result = null;')
+        return result
+
+
+    def close(self):
+        """Close the browser.
+
+        @return True.
+
+        """
+        self._chrome.browser.Close()
+        return True
+
+
+if __name__ == '__main__':
+    logging.basicConfig(level=logging.DEBUG)
+    handler = logging.handlers.SysLogHandler(address='/dev/log')
+    logging.getLogger().addHandler(handler)
+    logging.debug('interactive_xmlrpc_server main...')
+    server = xmlrpc_server.XmlRpcServer(
+            'localhost',
+            constants.INTERACTIVE_XMLRPC_SERVER_PORT)
+    server.register_delegate(InteractiveXmlRpcDelegate())
+    server.run()
diff --git a/client/common_lib/cros/shell.html b/client/common_lib/cros/shell.html
new file mode 100644
index 0000000..5e314cc
--- /dev/null
+++ b/client/common_lib/cros/shell.html
@@ -0,0 +1,25 @@
+<!--
+# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+#
+# This html file is used by Interactive Tests.  Scripts are able to append
+# HTML to the document, including buttons that can call submit_button() on
+# click and retrieve the results.
+-->
+<html>
+    <head>
+        <title>Interactive Test</title>
+        <script language="Javascript">
+            window.__ready = 0;
+            window.__result = null;
+            function submit_button(value) {
+                window.__result = value;
+                window.__ready = 1;
+            };
+        </script>
+    </head>
+    <body>
+
+    </body>
+</html>