Merge branch 'master' of /home/tammo/src/autotest into merge_at
diff --git a/py/event_log.py b/py/event_log.py
new file mode 100644
index 0000000..8b4adb7
--- /dev/null
+++ b/py/event_log.py
@@ -0,0 +1,259 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2012 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.
+
+"""Routines for producing event logs."""
+
+
+import logging
+import re
+import os
+import threading
+import time
+import yaml
+
+from uuid import uuid4
+
+import factory_common
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import utils
+
+
+class EventLogException(Exception):
+  pass
+
+
+# Since gooftool uses this.
+TimeString = utils.TimeString
+
+
+def TimedUuid():
+  """Returns a UUID that is roughly sorted by time.
+
+  The first 8 hexits are replaced by the current time in 100ths of a
+  second, mod 2**32.  This will roll over once every 490 days, but it
+  will cause UUIDs to be sorted by time in the vast majority of cases
+  (handy for ls'ing directories); and it still contains far more than
+  enough randomness to remain unique.
+  """
+  return ("%08x" % (int(time.time() * 100) & 0xFFFFFFFF) +
+          str(uuid4())[8:])
+
+
+def YamlDump(structured_data):
+  """Wrap yaml.dump to make calling convention consistent."""
+  return yaml.dump(structured_data,
+                   default_flow_style=False,
+                   allow_unicode=True)
+
+
+# TODO(tammo): Replace these definitions references to real canonical
+# data, once the corresponding modules move over from the autotest
+# repo.
+
+EVENT_LOG_DIR = os.path.join(factory.get_state_root(), "events")
+
+# Path to use to generate a device ID in case none exists (i.e.,
+# there is no wlan0 interface).
+DEVICE_ID_PATH = os.path.join(EVENT_LOG_DIR, ".device_id")
+
+# Path to use to generate an image ID in case none exists (i.e.,
+# this is the first time we're creating an event log).
+IMAGE_ID_PATH = os.path.join(EVENT_LOG_DIR, ".image_id")
+
+WLAN0_MAC_PATH = "/sys/class/net/wlan0/address"
+
+PREFIX_RE = re.compile("^[a-zA-Z0-9_\.]+$")
+EVENT_NAME_RE = re.compile("^[a-z0-9_]+$")
+EVENT_KEY_RE = EVENT_NAME_RE
+
+device_id = None
+image_id = None
+
+
+def GetDeviceId():
+  """Returns the device ID.
+
+  This is derived from the wlan0 MAC address.  If no wlan0 device is
+  available, one is generated into DEVICE_ID_PATH.
+  """
+  global device_id
+  if not device_id:
+    for path in [WLAN0_MAC_PATH, DEVICE_ID_PATH]:
+      if os.path.exists(path):
+        device_id = open(path).read().strip()
+        break
+    else:
+      device_id = str(uuid4())
+      with open(DEVICE_ID_PATH, "w") as f:
+        print >>f, device_id
+      logging.warning('No device ID available: generated %s', device_id)
+  return device_id
+
+
+def GetBootId():
+  """Returns the boot ID."""
+  return open("/proc/sys/kernel/random/boot_id", "r").read().strip()
+
+
+def GetImageId():
+  """Returns the image ID.
+
+  This is stored in IMAGE_ID_PATH; one is generated if not available.
+  """
+  global image_id
+  if not image_id:
+    if os.path.exists(IMAGE_ID_PATH):
+      image_id = open(IMAGE_ID_PATH).read().strip()
+    else:
+      image_id = str(uuid4())
+      with open(IMAGE_ID_PATH, "w") as f:
+        print >>f, image_id
+      logging.info('No image ID available yet: generated %s', image_id)
+  return image_id
+
+
+class EventLog(object):
+  """Event logger.
+
+  Properties:
+    lock: A lock guarding all properties.
+    file: The file object for logging.
+    prefix: The prefix for the log file.
+    seq: The current sequence number.
+    log_id: The ID of the log file.
+  """
+
+  @staticmethod
+  def ForAutoTest():
+    """Creates an EventLog object for the running autotest."""
+    path = os.environ.get('CROS_FACTORY_TEST_PATH', 'autotest')
+    uuid = os.environ.get('CROS_FACTORY_TEST_INVOCATION') or TimedUuid()
+    return EventLog(path, uuid)
+
+  def __init__(self, prefix, log_id=None, defer=True):
+    """Creates a new event log file, returning an EventLog instance.
+
+    A new file will be created of the form <prefix>-UUID, where UUID is
+    randomly generated.  The file will be initialized with a preamble
+    that includes the following fields:
+      device_id - Unique per device (eg, MAC addr).
+      image_id - Unique each time device is imaged.
+
+    Due to the generation of device and image IDs, the creation of the *first*
+    EventLog object is not thread-safe (i.e., one must be constructed completely
+    before any others can be constructed in other threads or processes).  After
+    that, construction of EventLogs and all EventLog operations are thread-safe.
+
+    Args:
+      prefix: String to identify this category of EventLog, to help
+        humans differentiate between event log files (since UUIDs all
+        look the same).  If string is not alphanumeric with period and
+        underscore punctuation, raises ValueError.
+      log_id: A UUID for the log (or None, in which case TimedUuid() is used)
+      defer: If True, then the file will not be written until the first
+        event is logged (if ever).
+    """
+    self.file = None
+    if not PREFIX_RE.match(prefix):
+      raise ValueError, "prefix %r must match re %s" % (
+        prefix, PREFIX_RE.pattern)
+    self.prefix = prefix
+    self.seq = 0
+    self.lock = threading.Lock()
+    self.log_id = log_id or TimedUuid()
+    self.filename = "%s-%s" % (prefix, self.log_id)
+    self.path = os.path.join(EVENT_LOG_DIR, self.filename)
+    logging.info('Logging events for %s to %s', self.prefix, self.path)
+    if os.path.exists(self.path):
+      raise EventLogException, "Log %s already exists" % self.path
+    self.opened = False
+
+    if not defer:
+      self._OpenUnlocked()
+
+  def Close(self):
+    """Closes associated log file."""
+    with self.lock:
+      if self.file:
+        self.file.close()
+        self.file = None
+
+  def Log(self, event_name, **kwargs):
+    """Writes new event stanza to log file, with consistent metadata.
+
+    Appends a stanza contain the following fields to the log:
+      TIME: Formatted as per TimeString().
+      SEQ: Monotonically increating counter.  The preamble will always
+        have SEQ=0.
+      EVENT - From event_name input.
+      ... - Other fields from kwargs.
+
+    Stanzas are terminated by "---\n".
+
+    Args:
+      event_name: Used to indentify event field.  Must be serialized
+        data, eg string or int.
+      kwargs: Dict of additional fields for inclusion in the event
+        stanza.  Field keys must be alphanumeric and lowercase. Field
+        values will be automatically yaml-ified.  Other data
+        types will result in a ValueError.
+    """
+    with self.lock:
+      self._LogUnlocked(event_name, **kwargs)
+
+  def _OpenUnlocked(self):
+    """Opens the file and writes the preamble (if not already open).
+
+    Requires that the lock has already been acquired.
+    """
+    dir = os.path.dirname(self.path)
+    if not os.path.exists(dir):
+      try:
+        os.makedirs(dir)
+      except:
+        # Maybe someone else tried to create it simultaneously
+        if not os.path.exists(dir):
+          raise
+
+    if self.opened:
+      return
+    self.opened = True
+
+    self.file = open(self.path, "w")
+    self._LogUnlocked("preamble",
+                      log_id=self.log_id,
+                      boot_id=GetBootId(),
+                      device_id=GetDeviceId(),
+                      image_id=GetImageId(),
+                      factory_md5sum=factory.get_current_md5sum(),
+                      filename=self.filename)
+
+  def _LogUnlocked(self, event_name, **kwargs):
+    """Same as Log, but requires that the lock has already been acquired.
+
+    See Log() for Args and Returns.
+    """
+    self._OpenUnlocked()
+
+    if self.file is None:
+      raise IOError, "cannot append to closed file for prefix %r" % (
+        self.prefix)
+    if not EVENT_NAME_RE.match(event_name):
+      raise ValueError, "event_name %r must match %s" % (
+        event_name, EVENT_NAME_RE.pattern)
+    for k in kwargs:
+      if not EVENT_KEY_RE.match(k):
+        raise ValueError, "key %r must match re %s" % (
+          k, EVENT_KEY_RE.pattern)
+    data = {
+        "EVENT": event_name,
+        "SEQ": self.seq,
+        "TIME": utils.TimeString()
+        }
+    data.update(kwargs)
+    self.file.write(YamlDump(data))
+    self.file.write("---\n")
+    self.file.flush()
+    self.seq += 1
diff --git a/py/event_log_unittest.py b/py/event_log_unittest.py
new file mode 100644
index 0000000..3539fa8
--- /dev/null
+++ b/py/event_log_unittest.py
@@ -0,0 +1,114 @@
+#!/usr/bin/python -u
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2012 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 factory_common
+import os
+import re
+import unittest
+import yaml
+
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import event_log
+
+MAC_RE = re.compile(r'^([a-f0-9]{2}:){5}[a-f0-9]{2}$')
+UUID_RE = re.compile(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-'
+                     '[a-f0-9]{4}-[a-f0-9]{12}$')
+
+
+class EventLogTest(unittest.TestCase):
+  def testGetBootId(self):
+    assert UUID_RE.match(event_log.GetBootId())
+
+  def testGetDeviceId(self):
+    device_id = event_log.GetDeviceId()
+    assert (MAC_RE.match(device_id) or
+            UUID_RE.match(device_id)), device_id
+
+    # Remove device_id and make sure we get the same thing
+    # back again, re-reading it from disk or the wlan0 interface
+    event_log.device_id = None
+    self.assertEqual(device_id, event_log.GetDeviceId())
+
+    self.assertNotEqual(device_id, event_log.GetImageId())
+
+  def testGetImageId(self):
+    image_id = event_log.GetImageId()
+    assert UUID_RE.match(image_id), image_id
+
+    # Remove image_id and make sure we get the same thing
+    # back again, re-reading it from disk
+    event_log.image_id = None
+    self.assertEqual(image_id, event_log.GetImageId())
+
+    # Remove the image_id file; now we should get something
+    # *different* back.
+    event_log.image_id = None
+    os.unlink(event_log.IMAGE_ID_PATH)
+    self.assertNotEqual(image_id, event_log.GetImageId())
+
+  def testEventLogDefer(self):
+    self._testEventLog(True)
+
+  def testEventLogNoDefer(self):
+    self._testEventLog(False)
+
+  def _testEventLog(self, defer):
+    log = event_log.EventLog('test', defer=defer)
+    self.assertEqual(os.path.exists(log.path), not defer)
+
+    event0 = dict(a='A',
+                  b=1,
+                  c=[1,2],
+                  d={'D1': 3, 'D2': 4},
+                  e=['E1', {'E2': 'E3'}],
+                  f=True,
+                  g=u"<<<囧>>>".encode('utf-8'))
+    log.Log('event0', **event0)
+    log.Log('event1')
+    log.Close()
+
+    try:
+      log.Log('should-fail')
+      self.fail('Expected exception')
+    except:
+      pass
+
+    log_data = list(yaml.load_all(open(log.path, "r")))
+    self.assertEqual(4, len(log_data))
+
+    for i in log_data[0:3]:
+      # Check and remove times, to make everything else easier to compare
+      assert re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$',
+                      i['TIME']), i['TIME']
+      del i['TIME']
+
+    self.assertEqual(
+      ['EVENT', 'SEQ', 'boot_id', 'device_id', 'filename', 'image_id',
+       'log_id'],
+      sorted(log_data[0].keys()))
+    self.assertEqual('preamble', log_data[0]['EVENT'])
+    self.assertEqual(0, log_data[0]['SEQ'])
+    self.assertEqual(event_log.GetBootId(), log_data[0]['boot_id'])
+    self.assertEqual(event_log.GetDeviceId(), log_data[0]['device_id'])
+    self.assertEqual(event_log.GetImageId(), log_data[0]['image_id'])
+    self.assertEqual(os.path.basename(log.path), log_data[0]['filename'])
+    self.assertEqual('test-' + log_data[0]['log_id'],
+                     log_data[0]['filename'])
+
+    event0.update(dict(EVENT='event0', SEQ=1))
+    self.assertEqual(event0, log_data[1])
+    self.assertEqual(dict(EVENT='event1', SEQ=2), log_data[2])
+    self.assertEqual(None, log_data[3])
+
+  def testDeferWithoutEvents(self):
+    log = event_log.EventLog('test', defer=True)
+    path = log.path
+    log.Close()
+    self.assertFalse(os.path.exists(path))
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/py/goofy/connection_manager.py b/py/goofy/connection_manager.py
new file mode 100644
index 0000000..c0bc47e
--- /dev/null
+++ b/py/goofy/connection_manager.py
@@ -0,0 +1,215 @@
+# Copyright (c) 2012 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 dbus
+import httplib
+import logging
+import os
+import re
+import subprocess
+import sys
+import time
+
+from autotest_lib.client.cros import flimflam_test_path
+
+try:
+  import flimflam
+except ImportError:
+  # E.g., in chroot
+  pass
+
+_CONNECTION_TIMEOUT_SEC = 15.0
+_PING_TIMEOUT_SEC = 15
+_SLEEP_INTERVAL_SEC = 0.5
+
+_UNKNOWN_PROC = 'unknown'
+_DEFAULT_MANAGER = 'flimflam'
+_DEFAULT_PROC_NAME = _UNKNOWN_PROC
+_MANAGER_LIST = ['flimflam', 'shill']
+_PROC_NAME_LIST = [_UNKNOWN_PROC, 'flimflamd', 'shill']
+
+
+class ConnectionManagerException(Exception):
+  pass
+
+
+class WLAN(object):
+  '''Class for wireless network settings.'''
+  def __init__(self, ssid, security, passphrase):
+    ''' Constructor.
+
+    Please see 'http://code.google.com/searchframe#wZuuyuB8jKQ/src/third_party/
+    flimflam/doc/service-api.txt' for a detailed explanation of these
+    parameters.
+
+    Args:
+      ssid: Wireless network SSID.
+      security: Wireless network security type.
+      passphrase: Wireless network password.
+    '''
+    self.ssid = ssid
+    self.security = security
+    self.passphrase = passphrase
+
+
+class ConnectionManager():
+
+  def __init__(self, wlans=None,
+               network_manager=_DEFAULT_MANAGER,
+               process_name=_DEFAULT_PROC_NAME,
+               start_enabled=True):
+    '''Constructor.
+
+    Args:
+      wlans: A list of preferred wireless networks and their properties.
+             Each item should be a WLAN object.
+      network_manager: The name of the network manager in initctl. It
+                       should be either flimflam(old) or shill(new).
+      process_name: The name of the network manager process, which should be
+                    flimflamd or shill. If you are not sure about it, you can
+                    use _UNKNOWN_PROC to let the class auto-detect it.
+      start_enabled: Whether networking should start enabled.
+    '''
+    # Black hole for those useless outputs.
+    self.fnull = open(os.devnull, 'w')
+
+    assert network_manager in _MANAGER_LIST
+    assert process_name in _PROC_NAME_LIST
+    self.network_manager = network_manager
+    self.process_name = process_name
+    # Auto-detect the network manager process name if unknown.
+    if self.process_name == _UNKNOWN_PROC:
+      self._DetectProcName()
+    if wlans is None:
+      wlans = []
+    self._ConfigureWifi(wlans)
+
+    # Start network manager to get device info.
+    self.EnableNetworking()
+    self._GetDeviceInfo()
+    if not start_enabled:
+      self.DisableNetworking()
+
+  def _DetectProcName(self):
+    '''Detects the network manager process with pgrep.'''
+    for process_name in _PROC_NAME_LIST[1:]:
+      if not subprocess.call("pgrep %s" % process_name,
+                             shell=True, stdout=self.fnull):
+        self.process_name = process_name
+        return
+    raise ConnectionManagerException("Can't find the network manager process")
+
+  def _GetDeviceInfo(self):
+    '''Gets hardware properties of all network devices.'''
+    flim = flimflam.FlimFlam()
+    self.device_list = [dev.GetProperties(utf8_strings=True)
+                        for dev in flim.GetObjectList("Device")]
+
+  def _ConfigureWifi(self, wlans):
+    '''Configures the wireless network settings.
+
+    The setting will let the network manager auto-connect the preferred
+    wireless networks.
+
+    Args:
+      wlans: A list of preferred wireless networks and their properties.
+             Each item should be a WLAN object.
+    '''
+    self.wlans = []
+    for wlan in wlans:
+      self.wlans.append({
+        'Type': 'wifi',
+        'Mode': 'managed',
+        'AutoConnect': True,
+        'SSID': wlan.ssid,
+        'Security': wlan.security,
+        'Passphrase': wlan.passphrase
+      })
+
+  def EnableNetworking(self):
+    '''Tells underlying connection manager to try auto-connecting.'''
+    # Start network manager.
+    subprocess.call("start %s" % self.network_manager, shell=True,
+                    stdout=self.fnull, stderr=self.fnull)
+
+    # Configure the network manager to auto-connect wireless networks.
+    flim = flimflam.FlimFlam()
+    for wlan in self.wlans:
+      flim.manager.ConfigureService(wlan)
+
+  def DisableNetworking(self):
+    '''Tells underlying connection manager to terminate any existing connection.
+    '''
+    # Stop network manager.
+    subprocess.call("stop %s" % self.network_manager, shell=True,
+                    stdout=self.fnull, stderr=self.fnull)
+
+    # Turn down drivers for interfaces to really stop the network.
+    for dev in self.device_list:
+      subprocess.call("ifconfig %s down" % dev['Interface'],
+                      shell=True, stdout=self.fnull, stderr=self.fnull)
+
+  def WaitForConnection(self, timeout=_CONNECTION_TIMEOUT_SEC):
+    '''A blocking function that waits until any network is connected.
+
+    The function will raise an Exception if no network is ready when
+    the time runs out.
+
+    Args:
+      timeout: Timeout in seconds.
+    '''
+    t_start = time.clock()
+    while not self.IsConnected():
+      if time.clock() - t_start > timeout:
+        raise ConnectionManagerException('Not connected')
+      time.sleep(_SLEEP_INTERVAL_SEC)
+
+  def IsConnected(self):
+    '''Returns (network state == online).'''
+    # Check if we are connected to any network.
+    # We can't cache the flimflam object because each time we re-start
+    # the network some filepaths that flimflam works on will change.
+    try:
+      flim = flimflam.FlimFlam()
+    except dbus.exceptions.DBusException:
+      # The network manager is not running.
+      return False
+
+    stat = flim.GetSystemState()
+    return stat != 'offline'
+
+
+class DummyConnectionManager(object):
+  '''A dummy connection manager that always reports being connected.
+
+  Useful, e.g., in the chroot.'''
+  def __init__(self):
+    pass
+
+  def DisableNetworking(self):
+    logging.warn('DisableNetworking: no network manager is set')
+
+  def EnableNetworking(self):
+    logging.warn('EnableNetworking: no network manager is set')
+
+  def WaitForConnection(self, timeout=_CONNECTION_TIMEOUT_SEC):
+    pass
+
+  def IsConnected(self):
+    return True
+
+def PingHost(host, timeout=_PING_TIMEOUT_SEC):
+  '''Checks if we can reach a host.
+
+  Args:
+    host: The host address.
+    timeout: Timeout in seconds. Integers only.
+
+  Returns:
+    True if host is successfully pinged.
+  '''
+  with open(os.devnull, "w") as fnull:
+    return subprocess.call(
+      "ping %s -c 1 -w %d" % (host, int(timeout)),
+      shell=True, stdout=fnull, stderr=fnull)
diff --git a/py/goofy/event_log_watcher.py b/py/goofy/event_log_watcher.py
new file mode 100644
index 0000000..7f54a6f
--- /dev/null
+++ b/py/goofy/event_log_watcher.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python
+#
+# Copyright (c) 2012 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 logging
+import os
+import shelve
+import threading
+import time
+
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import event_log
+
+EVENT_SEPARATOR = '\n---\n'
+KEY_OFFSET = 'offset'
+EVENT_LOG_DB_FILE = os.path.join(factory.get_state_root(), 'event_log_db')
+
+
+class ScanException(Exception):
+  pass
+
+
+class EventLogWatcher(object):
+  '''An object watches event log and invokes a callback as new logs appear.'''
+
+  def __init__(self, watch_period_sec=30,
+             event_log_dir=event_log.EVENT_LOG_DIR,
+             event_log_db_file=EVENT_LOG_DB_FILE,
+             handle_event_logs_callback=None):
+    '''Constructor.
+
+    Args:
+      watch_period_sec: The time period in seconds between consecutive
+          watches.
+      event_log_db_file: The file in which to store the DB of event logs.
+      handle_event_logs__callback: The callback to trigger after new event logs
+          found.
+    '''
+    self._watch_period_sec = watch_period_sec
+    self._event_log_dir = event_log_dir
+    self._event_log_db_file = event_log_db_file
+    self._handle_event_logs_callback = handle_event_logs_callback
+    self._watch_thread = None
+    self._aborted = threading.Event()
+    self._db = self.GetOrCreateDb()
+    self._scan_lock = threading.Lock()
+
+  def StartWatchThread(self):
+    '''Starts a thread to watch event logs.'''
+    logging.info('Start watching...')
+    self._watch_thread = threading.Thread(target=self.WatchForever)
+    self._watch_thread.start()
+
+  def FlushEventLogs(self):
+    '''Flushes event logs.
+
+    Call ScanEventLogs and with suppress_error flag to false.
+    '''
+    with self._scan_lock:
+      self.ScanEventLogs(False)
+
+  def ScanEventLogs(self, suppress_error=True):
+    '''Scans event logs.
+
+    Args:
+      suppress_error: if set to true then any exception from handle event
+          log callback will be ignored.
+
+    Raise:
+      ScanException: if at least one ScanEventLog call throws exception.
+    '''
+    exceptions = []
+    for file_name in os.listdir(self._event_log_dir):
+      file_path = os.path.join(self._event_log_dir, file_name)
+      if (not self._db.has_key(file_name) or
+          self._db[file_name][KEY_OFFSET] != os.path.getsize(file_path)):
+        try:
+          self.ScanEventLog(file_name)
+        except Exception, e:
+          logging.exception('Error scanning event log %s', file_name)
+          exceptions.append(e)
+
+    self._db.sync()
+    if not suppress_error and exceptions:
+      if len(exceptions) == 1:
+        raise ScanException('Log scan handler failed: %s' % exceptions[0])
+      else:
+        raise ScanException('%d log scan handlers failed (first is %s)' %
+                            (len(exceptions),
+                             str(exceptions[0])))
+
+  def StopWatchThread(self):
+    '''Stops the event logs watching thread.'''
+    self._aborted.set()
+    self._watch_thread.join()
+    self._watch_thread = None
+    logging.info('Stopped watching.')
+    self.Close()
+
+  def Close(self):
+    '''Closes the database.'''
+    self._db.close()
+
+  def WatchForever(self):
+    '''Watches event logs forever.'''
+    while True:
+      # Flush the event logs once every watch period.
+      self._aborted.wait(self._watch_period_sec)
+      if self._aborted.isSet():
+        return
+      try:
+        with self._scan_lock:
+          self.ScanEventLogs()
+      except Exception:
+        logging.exception('Error in event log watcher thread')
+
+  def GetOrCreateDb(self):
+    '''Gets the database or recreate one if exception occurs.'''
+    try:
+      db = shelve.open(self._event_log_db_file)
+    except Exception:
+      logging.exception('Corrupted database, recreating')
+      os.unlink(self._event_log_db_file)
+      db = shelve.open(self._event_log_db_file)
+    return db
+
+  def ScanEventLog(self, log_name):
+    '''Scans new generated event log.
+
+    Scans event logs in given file path and flush to our database.
+    If the log name has no record, create an empty event log for it.
+
+    Args:
+      log_name: name of the log file.
+
+    Raise:
+      Exception: propagate exception from handle_event_logs_callback.
+    '''
+    log_state = self._db.setdefault(log_name, {KEY_OFFSET: 0})
+
+    with open(os.path.join(self._event_log_dir, log_name)) as f:
+      f.seek(log_state[KEY_OFFSET])
+
+      chunk = f.read()
+      last_separator = chunk.rfind(EVENT_SEPARATOR)
+      # No need to proceed if available chunk is empty.
+      if last_separator == -1:
+        return
+
+      chunk = chunk[0:(last_separator + len(EVENT_SEPARATOR))]
+
+      if self._handle_event_logs_callback != None:
+        self._handle_event_logs_callback(log_name, chunk)
+
+      # Update log state to db.
+      log_state[KEY_OFFSET] += len(chunk)
+      self._db[log_name] = log_state
+
+  def GetEventLog(self, log_name):
+    '''Gets the log for given log name.'''
+    return self._db.get(log_name)
diff --git a/py/goofy/event_log_watcher_unittest.py b/py/goofy/event_log_watcher_unittest.py
new file mode 100755
index 0000000..ea3c2ab
--- /dev/null
+++ b/py/goofy/event_log_watcher_unittest.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python
+#
+# Copyright (c) 2012 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 factory_common
+
+import logging
+import mox
+import os
+import shutil
+import tempfile
+import time
+import unittest
+
+from autotest_lib.client.cros.factory import event_log_watcher
+from autotest_lib.client.cros.factory.event_log_watcher import EventLogWatcher
+
+MOCK_LOG_NAME = 'mylog12345'
+MOCK_PREAMBLE = 'device: 123\nimage: 456\nmd5: abc\n---\n'
+MOCK_EVENT = 'seq: 1\nevent: start\n---\n'
+MOCK_PERIOD = 0.01
+
+class EventLogWatcherTest(unittest.TestCase):
+  def setUp(self):
+    self.temp_dir = tempfile.mkdtemp()
+    self.events_dir = os.path.join(self.temp_dir, 'events')
+    os.mkdir(self.events_dir)
+    self.db = os.path.join(self.temp_dir, 'db')
+
+  def tearDown(self):
+    # Remove temp event log files and db files.
+    shutil.rmtree(self.temp_dir)
+
+  def WriteLog(self, content, file_name=None):
+    '''Writes text content into a log file.
+
+    If the given log file exists, new content will be appended.
+
+    Args:
+      content: the text content to write
+      file_name: the name of the log file we're written to
+    '''
+    file_path = ''
+    if file_name is None:
+      file_path = tempfile.NamedTemporaryFile(dir=self.events_dir,
+          delete=False).name
+    else:
+      file_path = os.path.join(self.events_dir, file_name)
+    with open(file_path, 'a') as f:
+      f.write(content)
+
+  def testWatchThread(self):
+    class Handler():
+      handled = False
+      def handle_cb(self, path, logs):
+        self.handled = True
+    h = Handler()
+
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db,
+                              h.handle_cb)
+    watcher.StartWatchThread()
+    self.WriteLog(MOCK_PREAMBLE)
+
+    # Assert handle_cb has ever been called in 2 seconds.
+    for i in range(200):
+      if h.handled:
+        break
+      time.sleep(MOCK_PERIOD)
+    else:
+      self.fail()
+
+    watcher.FlushEventLogs()
+    watcher.StopWatchThread()
+
+  def testWatch(self):
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db)
+
+    self.WriteLog(MOCK_PREAMBLE, MOCK_LOG_NAME)
+
+    # Assert nothing stored yet before scan.
+    self.assertEqual(watcher.GetEventLog(MOCK_LOG_NAME), None)
+
+    watcher.ScanEventLogs()
+
+    log = watcher.GetEventLog(MOCK_LOG_NAME)
+    self.assertNotEqual(log[event_log_watcher.KEY_OFFSET], 0)
+
+    # Write more logs and flush.
+    self.WriteLog(MOCK_EVENT, MOCK_LOG_NAME)
+    watcher.FlushEventLogs()
+
+    log = watcher.GetEventLog(MOCK_LOG_NAME)
+    self.assertEqual(log[event_log_watcher.KEY_OFFSET],
+        len(MOCK_PREAMBLE) + len(MOCK_EVENT))
+
+  def testCorruptDb(self):
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db)
+
+    self.WriteLog(MOCK_PREAMBLE, MOCK_LOG_NAME)
+
+    # Assert nothing stored yet before flush.
+    watcher.ScanEventLogs()
+    self.assertNotEqual(watcher.GetEventLog(MOCK_LOG_NAME), 0)
+
+    # Manually truncate db file.
+    with open(self.db, 'w') as f:
+      os.ftruncate(file.fileno(f), 10)
+
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db)
+    self.assertEqual(watcher.GetEventLog(MOCK_LOG_NAME), None)
+
+  def testHandleEventLogsCallback(self):
+    mock = mox.MockAnything()
+    mock.handle_event_log(MOCK_LOG_NAME, MOCK_PREAMBLE)
+    mox.Replay(mock)
+
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db,
+                              mock.handle_event_log)
+
+    self.WriteLog(MOCK_PREAMBLE, MOCK_LOG_NAME)
+    watcher.ScanEventLogs()
+
+    # Assert that the new log has been marked as handled.
+    log = watcher.GetEventLog(MOCK_LOG_NAME)
+    self.assertEqual(log[event_log_watcher.KEY_OFFSET],
+        len(MOCK_PREAMBLE))
+
+    mox.Verify(mock)
+
+  def testHandleEventLogsFail(self):
+    mock = mox.MockAnything()
+    mock.handle_event_log(MOCK_LOG_NAME, MOCK_PREAMBLE).AndRaise(
+            Exception("Bar"))
+    mox.Replay(mock)
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db,
+        mock.handle_event_log)
+
+    self.WriteLog(MOCK_PREAMBLE, MOCK_LOG_NAME)
+    watcher.ScanEventLogs()
+
+    # Assert that watcher did not update the new event as uploaded
+    # when handle logs fail.
+    log = watcher.GetEventLog(MOCK_LOG_NAME)
+    self.assertEqual(log[event_log_watcher.KEY_OFFSET], 0)
+    mox.Verify(mock)
+
+  def testFlushEventLogsFail(self):
+    mock = mox.MockAnything()
+    mock.handle_event_log(MOCK_LOG_NAME, MOCK_PREAMBLE).AndRaise(
+            Exception("Foo"))
+    mox.Replay(mock)
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db,
+        mock.handle_event_log)
+
+    self.WriteLog(MOCK_PREAMBLE, MOCK_LOG_NAME)
+
+    # Assert exception caught.
+    self.assertRaises(event_log_watcher.ScanException, watcher.FlushEventLogs)
+
+    # Assert that watcher did not update the new event as uploaded
+    # when handle logs fail.
+    log = watcher.GetEventLog(MOCK_LOG_NAME)
+    self.assertEqual(log[event_log_watcher.KEY_OFFSET], 0)
+    mox.Verify(mock)
+
+  def testIncompleteLog(self):
+    watcher = EventLogWatcher(MOCK_PERIOD, self.events_dir, self.db)
+
+    # Write the first line of mock preamble as incomplete event log.
+    self.WriteLog(MOCK_PREAMBLE[:12] , MOCK_LOG_NAME)
+    watcher.ScanEventLogs()
+
+    # Incomplete preamble should be ignored.
+    log = watcher.GetEventLog(MOCK_LOG_NAME)
+    self.assertEqual(log[event_log_watcher.KEY_OFFSET], 0)
+
+    self.WriteLog(MOCK_PREAMBLE[12:] , MOCK_LOG_NAME)
+    watcher.ScanEventLogs()
+
+    log = watcher.GetEventLog(MOCK_LOG_NAME)
+    self.assertNotEqual(log[event_log_watcher.KEY_OFFSET], 0)
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/py/goofy/goofy.py b/py/goofy/goofy.py
new file mode 100755
index 0000000..e6756ac
--- /dev/null
+++ b/py/goofy/goofy.py
@@ -0,0 +1,1032 @@
+#!/usr/bin/python -u
+#
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2012 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.
+
+'''
+The main factory flow that runs the factory test and finalizes a device.
+'''
+
+import array
+import fcntl
+import glob
+import logging
+import os
+import cPickle as pickle
+import pipes
+import Queue
+import re
+import signal
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import traceback
+import unittest
+import uuid
+from collections import deque
+from optparse import OptionParser
+from StringIO import StringIO
+
+import factory_common
+from autotest_lib.client.bin.prespawner import Prespawner
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import state
+from autotest_lib.client.cros.factory import TestState
+from autotest_lib.client.cros.factory import updater
+from autotest_lib.client.cros.factory import utils
+from autotest_lib.client.cros.factory.event import Event
+from autotest_lib.client.cros.factory.event import EventClient
+from autotest_lib.client.cros.factory.event import EventServer
+from autotest_lib.client.cros.factory.event_log import EventLog
+from autotest_lib.client.cros.factory.invocation import TestInvocation
+from autotest_lib.client.cros.factory import system
+from autotest_lib.client.cros.factory import test_environment
+from autotest_lib.client.cros.factory.web_socket_manager import WebSocketManager
+
+
+DEFAULT_TEST_LIST_PATH = os.path.join(
+        factory.CLIENT_PATH , 'site_tests', 'suite_Factory', 'test_list')
+HWID_CFG_PATH = '/usr/local/share/chromeos-hwid/cfg'
+
+# File that suppresses reboot if present (e.g., for development).
+NO_REBOOT_FILE = '/var/log/factory.noreboot'
+
+GOOFY_IN_CHROOT_WARNING = '\n' + ('*' * 70) + '''
+You are running Goofy inside the chroot.  Autotests are not supported.
+
+To use Goofy in the chroot, first install an Xvnc server:
+
+    sudo apt-get install tightvncserver
+
+...and then start a VNC X server outside the chroot:
+
+    vncserver :10 &
+    vncviewer :10
+
+...and run Goofy as follows:
+
+    env --unset=XAUTHORITY DISPLAY=localhost:10 python goofy.py
+''' + ('*' * 70)
+suppress_chroot_warning = False
+
+def get_hwid_cfg():
+    '''
+    Returns the HWID config tag, or an empty string if none can be found.
+    '''
+    if 'CROS_HWID' in os.environ:
+        return os.environ['CROS_HWID']
+    if os.path.exists(HWID_CFG_PATH):
+        with open(HWID_CFG_PATH, 'rt') as hwid_cfg_handle:
+            return hwid_cfg_handle.read().strip()
+    return ''
+
+
+def find_test_list():
+    '''
+    Returns the path to the active test list, based on the HWID config tag.
+    '''
+    hwid_cfg = get_hwid_cfg()
+
+    # Try in order: test_list, test_list.$hwid_cfg, test_list.all
+    if hwid_cfg:
+        test_list = '%s_%s' % (DEFAULT_TEST_LIST_PATH, hwid_cfg)
+        if os.path.exists(test_list):
+            logging.info('Using special test list: %s', test_list)
+            return test_list
+        logging.info('WARNING: no specific test list for config: %s', hwid_cfg)
+
+    test_list = DEFAULT_TEST_LIST_PATH
+    if os.path.exists(test_list):
+        return test_list
+
+    test_list = ('%s.all' % DEFAULT_TEST_LIST_PATH)
+    if os.path.exists(test_list):
+        logging.info('Using default test list: ' + test_list)
+        return test_list
+    logging.info('ERROR: Cannot find any test list.')
+
+
+_inited_logging = False
+
+class Goofy(object):
+    '''
+    The main factory flow.
+
+    Note that all methods in this class must be invoked from the main
+    (event) thread.  Other threads, such as callbacks and TestInvocation
+    methods, should instead post events on the run queue.
+
+    TODO: Unit tests. (chrome-os-partner:7409)
+
+    Properties:
+        uuid: A unique UUID for this invocation of Goofy.
+        state_instance: An instance of FactoryState.
+        state_server: The FactoryState XML/RPC server.
+        state_server_thread: A thread running state_server.
+        event_server: The EventServer socket server.
+        event_server_thread: A thread running event_server.
+        event_client: A client to the event server.
+        connection_manager: The connection_manager object.
+        network_enabled: Whether the connection_manager is currently
+            enabling connections.
+        ui_process: The factory ui process object.
+        run_queue: A queue of callbacks to invoke from the main thread.
+        invocations: A map from FactoryTest objects to the corresponding
+            TestInvocations objects representing active tests.
+        tests_to_run: A deque of tests that should be run when the current
+            test(s) complete.
+        options: Command-line options.
+        args: Command-line args.
+        test_list: The test list.
+        event_handlers: Map of Event.Type to the method used to handle that
+            event.  If the method has an 'event' argument, the event is passed
+            to the handler.
+        exceptions: Exceptions encountered in invocation threads.
+    '''
+    def __init__(self):
+        self.uuid = str(uuid.uuid4())
+        self.state_instance = None
+        self.state_server = None
+        self.state_server_thread = None
+        self.event_server = None
+        self.event_server_thread = None
+        self.event_client = None
+        self.connection_manager = None
+        self.network_enabled = True
+        self.event_log = None
+        self.prespawner = None
+        self.ui_process = None
+        self.run_queue = Queue.Queue()
+        self.invocations = {}
+        self.tests_to_run = deque()
+        self.visible_test = None
+        self.chrome = None
+
+        self.options = None
+        self.args = None
+        self.test_list = None
+        self.on_ui_startup = []
+
+        def test_or_root(event):
+            '''Returns the top-level parent for a test (the root node of the
+            tests that need to be run together if the given test path is to
+            be run).'''
+            try:
+                path = event.path
+            except AttributeError:
+                path = None
+
+            if path:
+                return (self.test_list.lookup_path(path).
+                        get_top_level_parent_or_group())
+            else:
+                return self.test_list
+
+        self.event_handlers = {
+            Event.Type.SWITCH_TEST: self.handle_switch_test,
+            Event.Type.SHOW_NEXT_ACTIVE_TEST:
+                lambda event: self.show_next_active_test(),
+            Event.Type.RESTART_TESTS:
+                lambda event: self.restart_tests(root=test_or_root(event)),
+            Event.Type.AUTO_RUN:
+                lambda event: self.auto_run(root=test_or_root(event)),
+            Event.Type.RE_RUN_FAILED:
+                lambda event: self.re_run_failed(root=test_or_root(event)),
+            Event.Type.RUN_TESTS_WITH_STATUS:
+                lambda event: self.run_tests_with_status(
+                    event.status,
+                    root=test_or_root(event)),
+            Event.Type.REVIEW:
+                lambda event: self.show_review_information(),
+            Event.Type.UPDATE_SYSTEM_INFO:
+                lambda event: self.update_system_info(),
+            Event.Type.UPDATE_FACTORY:
+                lambda event: self.update_factory(),
+            Event.Type.STOP:
+                lambda event: self.stop(),
+        }
+
+        self.exceptions = []
+        self.web_socket_manager = None
+
+    def destroy(self):
+        if self.chrome:
+            self.chrome.kill()
+            self.chrome = None
+        if self.ui_process:
+            utils.kill_process_tree(self.ui_process, 'ui')
+            self.ui_process = None
+        if self.web_socket_manager:
+            logging.info('Stopping web sockets')
+            self.web_socket_manager.close()
+            self.web_socket_manager = None
+        if self.state_server_thread:
+            logging.info('Stopping state server')
+            self.state_server.shutdown()
+            self.state_server_thread.join()
+            self.state_server.server_close()
+            self.state_server_thread = None
+        if self.state_instance:
+            self.state_instance.close()
+        if self.event_server_thread:
+            logging.info('Stopping event server')
+            self.event_server.shutdown()  # pylint: disable=E1101
+            self.event_server_thread.join()
+            self.event_server.server_close()
+            self.event_server_thread = None
+        if self.prespawner:
+            logging.info('Stopping prespawner')
+            self.prespawner.stop()
+            self.prespawner = None
+        if self.event_client:
+            logging.info('Closing event client')
+            self.event_client.close()
+            self.event_client = None
+        if self.event_log:
+            self.event_log.Close()
+            self.event_log = None
+        self.check_exceptions()
+        logging.info('Done destroying Goofy')
+
+    def start_state_server(self):
+        self.state_instance, self.state_server = (
+            state.create_server(bind_address='0.0.0.0'))
+        logging.info('Starting state server')
+        self.state_server_thread = threading.Thread(
+            target=self.state_server.serve_forever,
+            name='StateServer')
+        self.state_server_thread.start()
+
+    def start_event_server(self):
+        self.event_server = EventServer()
+        logging.info('Starting factory event server')
+        self.event_server_thread = threading.Thread(
+            target=self.event_server.serve_forever,
+            name='EventServer')  # pylint: disable=E1101
+        self.event_server_thread.start()
+
+        self.event_client = EventClient(
+            callback=self.handle_event, event_loop=self.run_queue)
+
+        self.web_socket_manager = WebSocketManager(self.uuid)
+        self.state_server.add_handler("/event",
+            self.web_socket_manager.handle_web_socket)
+
+    def start_ui(self):
+        ui_proc_args = [os.path.join(factory.CROS_FACTORY_LIB_PATH, 'ui'),
+                        self.options.test_list]
+        if self.options.verbose:
+            ui_proc_args.append('-v')
+        logging.info('Starting ui %s', ui_proc_args)
+        self.ui_process = subprocess.Popen(ui_proc_args)
+        logging.info('Waiting for UI to come up...')
+        self.event_client.wait(
+            lambda event: event.type == Event.Type.UI_READY)
+        logging.info('UI has started')
+
+    def set_visible_test(self, test):
+        if self.visible_test == test:
+            return
+
+        if test:
+            test.update_state(visible=True)
+        if self.visible_test:
+            self.visible_test.update_state(visible=False)
+        self.visible_test = test
+
+    def handle_shutdown_complete(self, test, state):
+        '''
+        Handles the case where a shutdown was detected during a shutdown step.
+
+        @param test: The ShutdownStep.
+        @param state: The test state.
+        '''
+        state = test.update_state(increment_shutdown_count=1)
+        logging.info('Detected shutdown (%d of %d)',
+                     state.shutdown_count, test.iterations)
+
+        def log_and_update_state(status, error_msg, **kw):
+            self.event_log.Log('rebooted',
+                               status=status, error_msg=error_msg, **kw)
+            test.update_state(status=status, error_msg=error_msg)
+
+        if not self.last_shutdown_time:
+            log_and_update_state(status=TestState.FAILED,
+                                 error_msg='Unable to read shutdown_time')
+            return
+
+        now = time.time()
+        logging.info('%.03f s passed since reboot',
+                     now - self.last_shutdown_time)
+
+        if self.last_shutdown_time > now:
+            test.update_state(status=TestState.FAILED,
+                              error_msg='Time moved backward during reboot')
+        elif (isinstance(test, factory.RebootStep) and
+              self.test_list.options.max_reboot_time_secs and
+              (now - self.last_shutdown_time >
+               self.test_list.options.max_reboot_time_secs)):
+            # A reboot took too long; fail.  (We don't check this for
+            # HaltSteps, because the machine could be halted for a
+            # very long time, and even unplugged with battery backup,
+            # thus hosing the clock.)
+            log_and_update_state(
+                status=TestState.FAILED,
+                error_msg=('More than %d s elapsed during reboot '
+                           '(%.03f s, from %s to %s)' % (
+                        self.test_list.options.max_reboot_time_secs,
+                        now - self.last_shutdown_time,
+                        utils.TimeString(self.last_shutdown_time),
+                        utils.TimeString(now))),
+                duration=(now-self.last_shutdown_time))
+        elif state.shutdown_count == test.iterations:
+            # Good!
+            log_and_update_state(status=TestState.PASSED,
+                                 duration=(now - self.last_shutdown_time),
+                                 error_msg='')
+        elif state.shutdown_count > test.iterations:
+            # Shut down too many times
+            log_and_update_state(status=TestState.FAILED,
+                                 error_msg='Too many shutdowns')
+        elif utils.are_shift_keys_depressed():
+            logging.info('Shift keys are depressed; cancelling restarts')
+            # Abort shutdown
+            log_and_update_state(
+                status=TestState.FAILED,
+                error_msg='Shutdown aborted with double shift keys')
+        else:
+            def handler():
+                if self._prompt_cancel_shutdown(test, state.shutdown_count + 1):
+                    log_and_update_state(
+                        status=TestState.FAILED,
+                        error_msg='Shutdown aborted by operator')
+                    return
+
+                # Time to shutdown again
+                log_and_update_state(
+                    status=TestState.ACTIVE,
+                    error_msg='',
+                    iteration=state.shutdown_count)
+
+                self.event_log.Log('shutdown', operation='reboot')
+                self.state_instance.set_shared_data('shutdown_time',
+                                                time.time())
+                self.env.shutdown('reboot')
+
+            self.on_ui_startup.append(handler)
+
+    def _prompt_cancel_shutdown(self, test, iteration):
+        if self.options.ui != 'chrome':
+            return False
+
+        pending_shutdown_data = {
+            'delay_secs': test.delay_secs,
+            'time': time.time() + test.delay_secs,
+            'operation': test.operation,
+            'iteration': iteration,
+            'iterations': test.iterations,
+            }
+
+        # Create a new (threaded) event client since we
+        # don't want to use the event loop for this.
+        with EventClient() as event_client:
+            event_client.post_event(Event(Event.Type.PENDING_SHUTDOWN,
+                                          **pending_shutdown_data))
+            aborted = event_client.wait(
+                lambda event: event.type == Event.Type.CANCEL_SHUTDOWN,
+                timeout=test.delay_secs) is not None
+            if aborted:
+                event_client.post_event(Event(Event.Type.PENDING_SHUTDOWN))
+            return aborted
+
+    def init_states(self):
+        '''
+        Initializes all states on startup.
+        '''
+        for test in self.test_list.get_all_tests():
+            # Make sure the state server knows about all the tests,
+            # defaulting to an untested state.
+            test.update_state(update_parent=False, visible=False)
+
+        var_log_messages = None
+
+        # Any 'active' tests should be marked as failed now.
+        for test in self.test_list.walk():
+            state = test.get_state()
+            if state.status != TestState.ACTIVE:
+                continue
+            if isinstance(test, factory.ShutdownStep):
+                # Shutdown while the test was active - that's good.
+                self.handle_shutdown_complete(test, state)
+            else:
+                # Unexpected shutdown.  Grab /var/log/messages for context.
+                if var_log_messages is None:
+                    try:
+                        var_log_messages = (
+                            utils.var_log_messages_before_reboot())
+                        # Write it to the log, to make it easier to
+                        # correlate with /var/log/messages.
+                        logging.info(
+                            'Unexpected shutdown. '
+                            'Tail of /var/log/messages before last reboot:\n'
+                            '%s', ('\n'.join(
+                                    '    ' + x for x in var_log_messages)))
+                    except:
+                        logging.exception('Unable to grok /var/log/messages')
+                        var_log_messages = []
+
+                error_msg = 'Unexpected shutdown while test was running'
+                self.event_log.Log('end_test',
+                                   path=test.path,
+                                   status=TestState.FAILED,
+                                   invocation=test.get_state().invocation,
+                                   error_msg=error_msg,
+                                   var_log_messages='\n'.join(var_log_messages))
+                test.update_state(
+                    status=TestState.FAILED,
+                    error_msg=error_msg)
+
+    def show_next_active_test(self):
+        '''
+        Rotates to the next visible active test.
+        '''
+        self.reap_completed_tests()
+        active_tests = [
+            t for t in self.test_list.walk()
+            if t.is_leaf() and t.get_state().status == TestState.ACTIVE]
+        if not active_tests:
+            return
+
+        try:
+            next_test = active_tests[
+                (active_tests.index(self.visible_test) + 1) % len(active_tests)]
+        except ValueError:  # visible_test not present in active_tests
+            next_test = active_tests[0]
+
+        self.set_visible_test(next_test)
+
+    def handle_event(self, event):
+        '''
+        Handles an event from the event server.
+        '''
+        handler = self.event_handlers.get(event.type)
+        if handler:
+            handler(event)
+        else:
+            # We don't register handlers for all event types - just ignore
+            # this event.
+            logging.debug('Unbound event type %s', event.type)
+
+    def run_next_test(self):
+        '''
+        Runs the next eligible test (or tests) in self.tests_to_run.
+        '''
+        self.reap_completed_tests()
+        while self.tests_to_run:
+            logging.debug('Tests to run: %s',
+                          [x.path for x in self.tests_to_run])
+
+            test = self.tests_to_run[0]
+
+            if test in self.invocations:
+                logging.info('Next test %s is already running', test.path)
+                self.tests_to_run.popleft()
+                return
+
+            if self.invocations and not (test.backgroundable and all(
+                [x.backgroundable for x in self.invocations])):
+                logging.debug('Waiting for non-backgroundable tests to '
+                              'complete before running %s', test.path)
+                return
+
+            self.tests_to_run.popleft()
+
+            if isinstance(test, factory.ShutdownStep):
+                if os.path.exists(NO_REBOOT_FILE):
+                    test.update_state(
+                        status=TestState.FAILED, increment_count=1,
+                        error_msg=('Skipped shutdown since %s is present' %
+                                   NO_REBOOT_FILE))
+                    continue
+
+                test.update_state(status=TestState.ACTIVE, increment_count=1,
+                                  error_msg='', shutdown_count=0)
+                if self._prompt_cancel_shutdown(test, 1):
+                    self.event_log.Log('reboot_cancelled')
+                    test.update_state(
+                        status=TestState.FAILED, increment_count=1,
+                        error_msg='Shutdown aborted by operator',
+                        shutdown_count=0)
+                    return
+
+                # Save pending test list in the state server
+                self.state_instance.set_shared_data(
+                    'tests_after_shutdown',
+                    [t.path for t in self.tests_to_run])
+                # Save shutdown time
+                self.state_instance.set_shared_data('shutdown_time',
+                                                    time.time())
+
+                with self.env.lock:
+                    self.event_log.Log('shutdown', operation=test.operation)
+                    shutdown_result = self.env.shutdown(test.operation)
+                if shutdown_result:
+                    # That's all, folks!
+                    self.run_queue.put(None)
+                    return
+                else:
+                    # Just pass (e.g., in the chroot).
+                    test.update_state(status=TestState.PASSED)
+                    self.state_instance.set_shared_data(
+                        'tests_after_shutdown', None)
+                    # Send event with no fields to indicate that there is no
+                    # longer a pending shutdown.
+                    self.event_client.post_event(Event(
+                            Event.Type.PENDING_SHUTDOWN))
+                    continue
+
+            invoc = TestInvocation(self, test, on_completion=self.run_next_test)
+            self.invocations[test] = invoc
+            if self.visible_test is None and test.has_ui:
+                self.set_visible_test(test)
+            self.check_connection_manager()
+            invoc.start()
+
+    def check_connection_manager(self):
+        exclusive_tests = [
+            test.path
+            for test in self.invocations
+            if test.is_exclusive(
+                factory.FactoryTest.EXCLUSIVE_OPTIONS.NETWORKING)]
+        if exclusive_tests:
+            # Make sure networking is disabled.
+            if self.network_enabled:
+                logging.info('Disabling network, as requested by %s',
+                             exclusive_tests)
+                self.connection_manager.DisableNetworking()
+                self.network_enabled = False
+        else:
+            # Make sure networking is enabled.
+            if not self.network_enabled:
+                logging.info('Re-enabling network')
+                self.connection_manager.EnableNetworking()
+                self.network_enabled = True
+
+    def run_tests(self, subtrees, untested_only=False):
+        '''
+        Runs tests under subtree.
+
+        The tests are run in order unless one fails (then stops).
+        Backgroundable tests are run simultaneously; when a foreground test is
+        encountered, we wait for all active tests to finish before continuing.
+
+        @param subtrees: Node or nodes containing tests to run (may either be
+            a single test or a list).  Duplicates will be ignored.
+        '''
+        if type(subtrees) != list:
+            subtrees = [subtrees]
+
+        # Nodes we've seen so far, to avoid duplicates.
+        seen = set()
+
+        self.tests_to_run = deque()
+        for subtree in subtrees:
+            for test in subtree.walk():
+                if test in seen:
+                    continue
+                seen.add(test)
+
+                if not test.is_leaf():
+                    continue
+                if (untested_only and
+                    test.get_state().status != TestState.UNTESTED):
+                    continue
+                self.tests_to_run.append(test)
+        self.run_next_test()
+
+    def reap_completed_tests(self):
+        '''
+        Removes completed tests from the set of active tests.
+
+        Also updates the visible test if it was reaped.
+        '''
+        for t, v in dict(self.invocations).iteritems():
+            if v.is_completed():
+                del self.invocations[t]
+
+        if (self.visible_test is None or
+            self.visible_test not in self.invocations):
+            self.set_visible_test(None)
+            # Make the first running test, if any, the visible test
+            for t in self.test_list.walk():
+                if t in self.invocations:
+                    self.set_visible_test(t)
+                    break
+
+    def kill_active_tests(self, abort):
+        '''
+        Kills and waits for all active tests.
+
+        @param abort: True to change state of killed tests to FAILED, False for
+                UNTESTED.
+        '''
+        self.reap_completed_tests()
+        for test, invoc in self.invocations.items():
+            factory.console.info('Killing active test %s...' % test.path)
+            invoc.abort_and_join()
+            factory.console.info('Killed %s' % test.path)
+            del self.invocations[test]
+            if not abort:
+                test.update_state(status=TestState.UNTESTED)
+        self.reap_completed_tests()
+
+    def stop(self):
+        self.kill_active_tests(False)
+        self.run_tests([])
+
+    def abort_active_tests(self):
+        self.kill_active_tests(True)
+
+    def main(self):
+        try:
+            self.init()
+            self.event_log.Log('goofy_init',
+                               success=True)
+        except:
+            if self.event_log:
+                try:
+                    self.event_log.Log('goofy_init',
+                                       success=False,
+                                       trace=traceback.format_exc())
+                except:
+                    pass
+            raise
+
+        self.run()
+
+    def update_system_info(self):
+        '''Updates system info.'''
+        system_info = system.SystemInfo()
+        self.state_instance.set_shared_data('system_info', system_info.__dict__)
+        self.event_client.post_event(Event(Event.Type.SYSTEM_INFO,
+                                           system_info=system_info.__dict__))
+        logging.info('System info: %r', system_info.__dict__)
+
+    def update_factory(self):
+        self.kill_active_tests(False)
+        self.run_tests([])
+
+        try:
+            if updater.TryUpdate(pre_update_hook=self.state_instance.close):
+                self.env.shutdown('reboot')
+        except:
+            factory.console.exception('Unable to update')
+
+    def init(self, args=None, env=None):
+        '''Initializes Goofy.
+
+        Args:
+            args: A list of command-line arguments.  Uses sys.argv if
+                args is None.
+            env: An Environment instance to use (or None to choose
+                FakeChrootEnvironment or DUTEnvironment as appropriate).
+        '''
+        parser = OptionParser()
+        parser.add_option('-v', '--verbose', dest='verbose',
+                          action='store_true',
+                          help='Enable debug logging')
+        parser.add_option('--print_test_list', dest='print_test_list',
+                          metavar='FILE',
+                          help='Read and print test list FILE, and exit')
+        parser.add_option('--restart', dest='restart',
+                          action='store_true',
+                          help='Clear all test state')
+        parser.add_option('--ui', dest='ui', type='choice',
+                          choices=['none', 'gtk', 'chrome'],
+                          default='gtk',
+                          help='UI to use')
+        parser.add_option('--ui_scale_factor', dest='ui_scale_factor',
+                          type='int', default=1,
+                          help=('Factor by which to scale UI '
+                                '(Chrome UI only)'))
+        parser.add_option('--test_list', dest='test_list',
+                          metavar='FILE',
+                          help='Use FILE as test list')
+        (self.options, self.args) = parser.parse_args(args)
+
+        global _inited_logging
+        if not _inited_logging:
+            factory.init_logging('goofy', verbose=self.options.verbose)
+            _inited_logging = True
+        self.event_log = EventLog('goofy')
+
+        if (not suppress_chroot_warning and
+            factory.in_chroot() and
+            self.options.ui == 'gtk' and
+            os.environ.get('DISPLAY') in [None, '', ':0', ':0.0']):
+            # That's not going to work!  Tell the user how to run
+            # this way.
+            logging.warn(GOOFY_IN_CHROOT_WARNING)
+            time.sleep(1)
+
+        if env:
+            self.env = env
+        elif factory.in_chroot():
+            self.env = test_environment.FakeChrootEnvironment()
+            logging.warn(
+                'Using chroot environment: will not actually run autotests')
+        else:
+            self.env = test_environment.DUTEnvironment()
+        self.env.goofy = self
+
+        if self.options.restart:
+            state.clear_state()
+
+        if self.options.print_test_list:
+            print (factory.read_test_list(self.options.print_test_list).
+                   __repr__(recursive=True))
+            return
+
+        if self.options.ui_scale_factor != 1 and utils.in_qemu():
+            logging.warn(
+                'In QEMU; ignoring ui_scale_factor argument')
+            self.options.ui_scale_factor = 1
+
+        logging.info('Started')
+
+        self.start_state_server()
+        self.state_instance.set_shared_data('hwid_cfg', get_hwid_cfg())
+        self.state_instance.set_shared_data('ui_scale_factor',
+                                            self.options.ui_scale_factor)
+        self.last_shutdown_time = (
+            self.state_instance.get_shared_data('shutdown_time', optional=True))
+        self.state_instance.del_shared_data('shutdown_time', optional=True)
+
+        self.options.test_list = (self.options.test_list or find_test_list())
+        self.test_list = factory.read_test_list(self.options.test_list,
+                                                self.state_instance)
+        if not self.state_instance.has_shared_data('ui_lang'):
+            self.state_instance.set_shared_data('ui_lang',
+                                                self.test_list.options.ui_lang)
+        self.state_instance.set_shared_data(
+            'test_list_options',
+            self.test_list.options.__dict__)
+        self.state_instance.test_list = self.test_list
+
+        self.init_states()
+        self.start_event_server()
+        self.connection_manager = self.env.create_connection_manager(
+            self.test_list.options.wlans)
+
+        self.update_system_info()
+
+        os.environ['CROS_FACTORY'] = '1'
+        os.environ['CROS_DISABLE_SITE_SYSINFO'] = '1'
+
+        # Set CROS_UI since some behaviors in ui.py depend on the
+        # particular UI in use.  TODO(jsalz): Remove this (and all
+        # places it is used) when the GTK UI is removed.
+        os.environ['CROS_UI'] = self.options.ui
+
+        if self.options.ui == 'chrome':
+            self.env.launch_chrome()
+            logging.info('Waiting for a web socket connection')
+            self.web_socket_manager.wait()
+
+            # Wait for the test widget size to be set; this is done in
+            # an asynchronous RPC so there is a small chance that the
+            # web socket might be opened first.
+            for i in range(100):  # 10 s
+                try:
+                    if self.state_instance.get_shared_data('test_widget_size'):
+                        break
+                except KeyError:
+                    pass  # Retry
+                time.sleep(0.1)  # 100 ms
+            else:
+                logging.warn('Never received test_widget_size from UI')
+        elif self.options.ui == 'gtk':
+            self.start_ui()
+
+        for handler in self.on_ui_startup:
+            handler()
+
+        self.prespawner = Prespawner()
+        self.prespawner.start()
+
+        def state_change_callback(test, state):
+            self.event_client.post_event(
+                Event(Event.Type.STATE_CHANGE,
+                      path=test.path, state=state))
+        self.test_list.state_change_callback = state_change_callback
+
+        try:
+            tests_after_shutdown = self.state_instance.get_shared_data(
+                'tests_after_shutdown')
+        except KeyError:
+            tests_after_shutdown = None
+
+        if tests_after_shutdown is not None:
+            logging.info('Resuming tests after shutdown: %s',
+                         tests_after_shutdown)
+            self.state_instance.set_shared_data('tests_after_shutdown', None)
+            self.tests_to_run.extend(
+                self.test_list.lookup_path(t) for t in tests_after_shutdown)
+            self.run_queue.put(self.run_next_test)
+        else:
+            if self.test_list.options.auto_run_on_start:
+                self.run_queue.put(
+                    lambda: self.run_tests(self.test_list, untested_only=True))
+
+    def run(self):
+        '''Runs Goofy.'''
+        # Process events forever.
+        while self.run_once(True):
+            pass
+
+    def run_once(self, block=False):
+        '''Runs all items pending in the event loop.
+
+        Args:
+            block: If true, block until at least one event is processed.
+
+        Returns:
+            True to keep going or False to shut down.
+        '''
+        events = utils.DrainQueue(self.run_queue)
+        if not events:
+            # Nothing on the run queue.
+            self._run_queue_idle()
+            if block:
+                # Block for at least one event...
+                events.append(self.run_queue.get())
+                # ...and grab anything else that showed up at the same
+                # time.
+                events.extend(utils.DrainQueue(self.run_queue))
+
+        for event in events:
+            if not event:
+                # Shutdown request.
+                self.run_queue.task_done()
+                return False
+
+            try:
+                event()
+            except Exception as e:  # pylint: disable=W0703
+                logging.error('Error in event loop: %s', e)
+                traceback.print_exc(sys.stderr)
+                self.record_exception(traceback.format_exception_only(
+                        *sys.exc_info()[:2]))
+                # But keep going
+            finally:
+                self.run_queue.task_done()
+        return True
+
+    def _run_queue_idle(self):
+        '''Invoked when the run queue has no events.'''
+        self.check_connection_manager()
+
+    def run_tests_with_status(self, statuses_to_run, starting_at=None,
+        root=None):
+        '''Runs all top-level tests with a particular status.
+
+        All active tests, plus any tests to re-run, are reset.
+
+        Args:
+            starting_at: If provided, only auto-runs tests beginning with
+                this test.
+        '''
+        root = root or self.test_list
+
+        if starting_at:
+            # Make sure they passed a test, not a string.
+            assert isinstance(starting_at, factory.FactoryTest)
+
+        tests_to_reset = []
+        tests_to_run = []
+
+        found_starting_at = False
+
+        for test in root.get_top_level_tests():
+            if starting_at:
+                if test == starting_at:
+                    # We've found starting_at; do auto-run on all
+                    # subsequent tests.
+                    found_starting_at = True
+                if not found_starting_at:
+                    # Don't start this guy yet
+                    continue
+
+            status = test.get_state().status
+            if status == TestState.ACTIVE or status in statuses_to_run:
+                # Reset the test (later; we will need to abort
+                # all active tests first).
+                tests_to_reset.append(test)
+            if status in statuses_to_run:
+                tests_to_run.append(test)
+
+        self.abort_active_tests()
+
+        # Reset all statuses of the tests to run (in case any tests were active;
+        # we want them to be run again).
+        for test_to_reset in tests_to_reset:
+            for test in test_to_reset.walk():
+                test.update_state(status=TestState.UNTESTED)
+
+        self.run_tests(tests_to_run, untested_only=True)
+
+    def restart_tests(self, root=None):
+        '''Restarts all tests.'''
+        root = root or self.test_list
+
+        self.abort_active_tests()
+        for test in root.walk():
+            test.update_state(status=TestState.UNTESTED)
+        self.run_tests(root)
+
+    def auto_run(self, starting_at=None, root=None):
+        '''"Auto-runs" tests that have not been run yet.
+
+        Args:
+            starting_at: If provide, only auto-runs tests beginning with
+                this test.
+        '''
+        root = root or self.test_list
+        self.run_tests_with_status([TestState.UNTESTED, TestState.ACTIVE],
+                                   starting_at=starting_at,
+                                   root=root)
+
+    def re_run_failed(self, root=None):
+        '''Re-runs failed tests.'''
+        root = root or self.test_list
+        self.run_tests_with_status([TestState.FAILED], root=root)
+
+    def show_review_information(self):
+        '''Event handler for showing review information screen.
+
+        The information screene is rendered by main UI program (ui.py), so in
+        goofy we only need to kill all active tests, set them as untested, and
+        clear remaining tests.
+        '''
+        self.kill_active_tests(False)
+        self.run_tests([])
+
+    def handle_switch_test(self, event):
+        '''Switches to a particular test.
+
+        @param event: The SWITCH_TEST event.
+        '''
+        test = self.test_list.lookup_path(event.path)
+        if not test:
+            logging.error('Unknown test %r', event.key)
+            return
+
+        invoc = self.invocations.get(test)
+        if invoc and test.backgroundable:
+            # Already running: just bring to the front if it
+            # has a UI.
+            logging.info('Setting visible test to %s', test.path)
+            self.event_client.post_event(
+                Event(Event.Type.SET_VISIBLE_TEST, path=test.path))
+            return
+
+        self.abort_active_tests()
+        for t in test.walk():
+            t.update_state(status=TestState.UNTESTED)
+
+        if self.test_list.options.auto_run_on_keypress:
+            self.auto_run(starting_at=test)
+        else:
+            self.run_tests(test)
+
+    def wait(self):
+        '''Waits for all pending invocations.
+
+        Useful for testing.
+        '''
+        for k, v in self.invocations.iteritems():
+            logging.info('Waiting for %s to complete...', k)
+            v.thread.join()
+
+    def check_exceptions(self):
+        '''Raises an error if any exceptions have occurred in
+        invocation threads.'''
+        if self.exceptions:
+            raise RuntimeError('Exception in invocation thread: %r' %
+                               self.exceptions)
+
+    def record_exception(self, msg):
+        '''Records an exception in an invocation thread.
+
+        An exception with the given message will be rethrown when
+        Goofy is destroyed.'''
+        self.exceptions.append(msg)
+
+
+if __name__ == '__main__':
+    Goofy().main()
diff --git a/py/goofy/goofy_unittest.py b/py/goofy/goofy_unittest.py
new file mode 100755
index 0000000..27a4fc4
--- /dev/null
+++ b/py/goofy/goofy_unittest.py
@@ -0,0 +1,424 @@
+#!/usr/bin/python -u
+#
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2012 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 factory_common
+
+import logging
+import math
+import mox
+import pickle
+import re
+import subprocess
+import tempfile
+import threading
+import time
+import unittest
+from Queue import Queue
+
+from mox import IgnoreArg
+from ws4py.client import WebSocketBaseClient
+
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import goofy
+from autotest_lib.client.cros.factory import state
+from autotest_lib.client.cros.factory import TestState
+from autotest_lib.client.cros.factory.event import Event
+from autotest_lib.client.cros.factory.goofy import Goofy
+from autotest_lib.client.cros.factory.connection_manager \
+    import ConnectionManager
+from autotest_lib.client.cros.factory.test_environment import Environment
+
+
+def init_goofy(env=None, test_list=None, options='', restart=True, ui='none'):
+    '''Initializes and returns a Goofy.'''
+    goofy = Goofy()
+    args = ['--ui', ui]
+    if restart:
+        args.append('--restart')
+    if test_list:
+        out = tempfile.NamedTemporaryFile(prefix='test_list', delete=False)
+
+        # Remove whitespace at the beginning of each line of options.
+        options = re.sub('(?m)^\s+', '', options)
+        out.write('TEST_LIST = [' + test_list + ']\n' + options)
+        out.close()
+        args.extend(['--test_list', out.name])
+    logging.info('Running goofy with args %r', args)
+    goofy.init(args, env or Environment())
+    return goofy
+
+
+def mock_autotest(env, name, passed, error_msg):
+    '''Adds a side effect that a mock autotest will be executed.
+
+    Args:
+        name: The name of the autotest to be mocked.
+        passed: Whether the test should pass.
+        error_msg: The error message.
+    '''
+    def side_effect(name, args, env_additions, result_file):
+        with open(result_file, 'w') as out:
+            pickle.dump((passed, error_msg), out)
+            return subprocess.Popen(['true'])
+
+    env.spawn_autotest(
+        name, IgnoreArg(), IgnoreArg(), IgnoreArg()).WithSideEffects(
+        side_effect)
+
+
+class GoofyTest(unittest.TestCase):
+    '''Base class for Goofy test cases.'''
+    options = ''
+    ui = 'none'
+    expected_create_connection_manager_arg = []
+
+    def setUp(self):
+        self.mocker = mox.Mox()
+        self.env = self.mocker.CreateMock(Environment)
+        self.state = state.get_instance()
+        self.connection_manager = self.mocker.CreateMock(ConnectionManager)
+        self.env.create_connection_manager(
+            self.expected_create_connection_manager_arg).AndReturn(
+            self.connection_manager)
+        self.before_init_goofy()
+        self.mocker.ReplayAll()
+        self.goofy = init_goofy(self.env, self.test_list, self.options,
+                                ui=self.ui)
+        self.mocker.VerifyAll()
+        self.mocker.ResetAll()
+
+    def tearDown(self):
+        self.goofy.destroy()
+
+        # Make sure we're not leaving any extra threads hanging around
+        # after a second.
+        for _ in range(10):
+            extra_threads = [t for t in threading.enumerate()
+                             if t != threading.current_thread()]
+            if not extra_threads:
+                break
+            logging.info('Waiting for %d threads to die', len(extra_threads))
+
+            # Wait another 100 ms
+            time.sleep(.1)
+
+        self.assertEqual([], extra_threads)
+
+    def _wait(self):
+        '''Waits for any pending invocations in Goofy to complete,
+        and verifies and resets all mocks.'''
+        self.goofy.wait()
+        self.mocker.VerifyAll()
+        self.mocker.ResetAll()
+
+    def before_init_goofy(self):
+        '''Hook invoked before init_goofy.'''
+
+    def check_one_test(self, id, name, passed, error_msg, trigger=None):
+        '''Runs a single autotest, waiting for it to complete.
+
+        Args:
+            id: The ID of the test expected to run.
+            name: The autotest name of the test expected to run.
+            passed: Whether the test should pass.
+            error_msg: The error message, if any.
+            trigger: An optional callable that will be executed after mocks are
+                set up to trigger the autotest.  If None, then the test is
+                expected to start itself.
+        '''
+        mock_autotest(self.env, name, passed, error_msg)
+        self.mocker.ReplayAll()
+        if trigger:
+            trigger()
+        self.assertTrue(self.goofy.run_once())
+        self.assertEqual([id],
+                         [test.path for test in self.goofy.invocations])
+        self._wait()
+        state = self.state.get_test_state(id)
+        self.assertEqual(TestState.PASSED if passed else TestState.FAILED,
+                         state.status)
+        self.assertEqual(1, state.count)
+        self.assertEqual(error_msg, state.error_msg)
+
+
+# A simple test list with three tests.
+ABC_TEST_LIST = '''
+    OperatorTest(id='a', autotest_name='a_A'),
+    OperatorTest(id='b', autotest_name='b_B'),
+    OperatorTest(id='c', autotest_name='c_C'),
+'''
+
+
+class BasicTest(GoofyTest):
+    '''A simple test case that checks that tests are run in the correct
+    order.'''
+    test_list = ABC_TEST_LIST
+    def runTest(self):
+        self.check_one_test('a', 'a_A', True, '')
+        self.check_one_test('b', 'b_B', False, 'Uh-oh')
+        self.check_one_test('c', 'c_C', False, 'Uh-oh')
+
+
+class WebSocketTest(GoofyTest):
+    '''A test case that checks the behavior of web sockets.'''
+    test_list = ABC_TEST_LIST
+    ui = 'chrome'
+
+    def before_init_goofy(self):
+        # Keep a record of events we received
+        self.events = []
+        # Trigger this event once the web socket closes
+        self.ws_done = threading.Event()
+
+        class MyClient(WebSocketBaseClient):
+            def handshake_ok(socket_self):
+                pass
+
+            def received_message(socket_self, message):
+                event = Event.from_json(str(message))
+                logging.info('Test client received %s', event)
+                self.events.append(event)
+                if event.type == Event.Type.HELLO:
+                    socket_self.send(Event(Event.Type.KEEPALIVE,
+                                           uuid=event.uuid).to_json())
+
+        ws = MyClient(
+            'http://localhost:%d/event' % state.DEFAULT_FACTORY_STATE_PORT,
+            protocols=None, extensions=None)
+
+        def open_web_socket():
+            ws.connect()
+            # Simulate setting the test widget size/position, since goofy
+            # waits for it.
+            factory.set_shared_data('test_widget_size', [100, 200],
+                                    'test_widget_position', [300, 400])
+            ws.run()
+            self.ws_done.set()
+        self.env.launch_chrome().WithSideEffects(
+            lambda: threading.Thread(target=open_web_socket).start()
+            ).AndReturn(None)
+
+    def runTest(self):
+        self.check_one_test('a', 'a_A', True, '')
+        self.check_one_test('b', 'b_B', False, 'Uh-oh')
+        self.check_one_test('c', 'c_C', False, 'Uh-oh')
+
+        # Kill Goofy and wait for the web socket to close gracefully
+        self.goofy.destroy()
+        self.ws_done.wait()
+
+        events_by_type = {}
+        for event in self.events:
+            events_by_type.setdefault(event.type, []).append(event)
+
+        # There should be one hello event
+        self.assertEqual(1, len(events_by_type[Event.Type.HELLO]))
+
+        # There should be at least one log event
+        self.assertTrue(Event.Type.LOG in events_by_type), repr(events_by_type)
+
+        # Each test should have a transition to active and to its
+        # final state
+        for path, final_status in (('a', TestState.PASSED),
+                                   ('b', TestState.FAILED),
+                                   ('c', TestState.FAILED)):
+            statuses = [
+                event.state['status']
+                for event in events_by_type[Event.Type.STATE_CHANGE]
+                if event.path == path]
+            self.assertEqual(
+                ['UNTESTED', 'ACTIVE', final_status],
+                statuses)
+
+
+class ShutdownTest(GoofyTest):
+    test_list = '''
+        RebootStep(id='shutdown', iterations=3),
+        OperatorTest(id='a', autotest_name='a_A')
+    '''
+    def runTest(self):
+        # Expect a reboot request
+        self.env.shutdown('reboot').AndReturn(True)
+        self.mocker.ReplayAll()
+        self.assertTrue(self.goofy.run_once())
+        self._wait()
+
+        # That should have enqueued a task that will cause Goofy
+        # to shut down.
+        self.mocker.ReplayAll()
+        self.assertFalse(self.goofy.run_once())
+        # There should be a list of tests to run on wake-up.
+        self.assertEqual(
+            ['a'], self.state.get_shared_data('tests_after_shutdown'))
+        self._wait()
+
+        # Kill and restart Goofy to simulate a shutdown.
+        # Goofy should call for another shutdown.
+        for _ in range(2):
+            self.env.create_connection_manager([]).AndReturn(
+                self.connection_manager)
+            self.env.shutdown('reboot').AndReturn(True)
+            self.mocker.ReplayAll()
+            self.goofy.destroy()
+            self.goofy = init_goofy(self.env, self.test_list, restart=False)
+            self._wait()
+
+        # No more shutdowns - now 'a' should run.
+        self.check_one_test('a', 'a_A', True, '')
+
+
+class RebootFailureTest(GoofyTest):
+    test_list = '''
+        RebootStep(id='shutdown'),
+    '''
+    def runTest(self):
+        # Expect a reboot request
+        self.env.shutdown('reboot').AndReturn(True)
+        self.mocker.ReplayAll()
+        self.assertTrue(self.goofy.run_once())
+        self._wait()
+
+        # That should have enqueued a task that will cause Goofy
+        # to shut down.
+        self.mocker.ReplayAll()
+        self.assertFalse(self.goofy.run_once())
+        self._wait()
+
+        # Something pretty close to the current time should be written
+        # as the shutdown time.
+        shutdown_time = self.state.get_shared_data('shutdown_time')
+        self.assertTrue(math.fabs(time.time() - shutdown_time) < 2)
+
+        # Fudge the shutdown time to be a long time ago.
+        self.state.set_shared_data(
+            'shutdown_time',
+            time.time() - (factory.Options.max_reboot_time_secs + 1))
+
+        # Kill and restart Goofy to simulate a reboot.
+        # Goofy should fail the test since it has been too long.
+        self.goofy.destroy()
+
+        self.mocker.ResetAll()
+        self.env.create_connection_manager([]).AndReturn(
+            self.connection_manager)
+        self.mocker.ReplayAll()
+        self.goofy = init_goofy(self.env, self.test_list, restart=False)
+        self._wait()
+
+        state = factory.get_state_instance().get_test_state('shutdown')
+        self.assertEquals(TestState.FAILED, state.status)
+        logging.info('%s', state.error_msg)
+        self.assertTrue(state.error_msg.startswith(
+                'More than %d s elapsed during reboot' %
+                factory.Options.max_reboot_time_secs))
+
+
+class NoAutoRunTest(GoofyTest):
+    test_list = ABC_TEST_LIST
+    options = 'options.auto_run_on_start = False'
+
+    def _runTestB(self):
+        # There shouldn't be anything to do at startup, since auto_run_on_start
+        # is unset.
+        self.mocker.ReplayAll()
+        self.goofy.run_once()
+        self.assertEqual({}, self.goofy.invocations)
+        self._wait()
+
+        # Tell Goofy to run 'b'.
+        self.check_one_test(
+            'b', 'b_B', True, '',
+            trigger=lambda: self.goofy.handle_switch_test(
+                Event(Event.Type.SWITCH_TEST, path='b')))
+
+    def runTest(self):
+        self._runTestB()
+        # No more tests to run now.
+        self.mocker.ReplayAll()
+        self.goofy.run_once()
+        self.assertEqual({}, self.goofy.invocations)
+
+
+class AutoRunKeypressTest(NoAutoRunTest):
+    test_list = ABC_TEST_LIST
+    options = '''
+        options.auto_run_on_start = False
+        options.auto_run_on_keypress = True
+    '''
+
+    def runTest(self):
+        self._runTestB()
+        # Unlike in NoAutoRunTest, C should now be run.
+        self.check_one_test('c', 'c_C', True, '')
+
+
+class PyTestTest(GoofyTest):
+    '''Tests the Python test driver.
+
+    Note that no mocks are used here, since it's easy enough to just have the
+    Python driver run a 'real' test (execpython).
+    '''
+    test_list = '''
+        OperatorTest(id='a', pytest_name='execpython',
+                     dargs={'script': 'assert "Tomato" == "Tomato"'}),
+        OperatorTest(id='b', pytest_name='execpython',
+                     dargs={'script': ("assert 'Pa-TAY-to' == 'Pa-TAH-to', "
+                                       "Let's call the whole thing off")})
+    '''
+    def runTest(self):
+        self.goofy.run_once()
+        self.assertEquals(['a'],
+                          [test.id for test in self.goofy.invocations])
+        self.goofy.wait()
+        self.assertEquals(
+            TestState.PASSED,
+            factory.get_state_instance().get_test_state('a').status)
+
+        self.goofy.run_once()
+        self.assertEquals(['b'],
+                          [test.id for test in self.goofy.invocations])
+        self.goofy.wait()
+        failed_state = factory.get_state_instance().get_test_state('b')
+        self.assertEquals(TestState.FAILED, failed_state.status)
+        self.assertTrue(
+            '''Let\'s call the whole thing off''' in failed_state.error_msg,
+            failed_state.error_msg)
+
+
+class ConnectionManagerTest(GoofyTest):
+    options = '''
+       options.wlans = [WLAN('foo', 'bar', 'baz')]
+    '''
+    test_list = '''
+       OperatorTest(id='a', autotest_name='a_A'),
+       TestGroup(id='b', exclusive='NETWORKING', subtests=[
+          OperatorTest(id='b1', autotest_name='b_B1'),
+          OperatorTest(id='b2', autotest_name='b_B2'),
+       ]),
+       OperatorTest(id='c', autotest_name='c_C'),
+    '''
+    expected_create_connection_manager_arg = mox.Func(
+        lambda arg: (len(arg) == 1 and
+                     arg[0].__dict__ == dict(ssid='foo',
+                                             security='bar',
+                                             passphrase='baz')))
+    def runTest(self):
+        self.check_one_test('a', 'a_A', True, '')
+        self.connection_manager.DisableNetworking()
+        self.check_one_test('b.b1', 'b_B1', False, 'Uh-oh')
+        self.check_one_test('b.b2', 'b_B2', False, 'Uh-oh')
+        self.connection_manager.EnableNetworking()
+        self.check_one_test('c', 'c_C', True, '')
+
+
+if __name__ == "__main__":
+    factory.init_logging('goofy_unittest')
+    goofy._inited_logging = True
+    goofy.suppress_chroot_warning = True
+
+    unittest.main()
diff --git a/py/goofy/invocation.py b/py/goofy/invocation.py
new file mode 100755
index 0000000..2d3658f
--- /dev/null
+++ b/py/goofy/invocation.py
@@ -0,0 +1,455 @@
+#!/usr/bin/python -u
+#
+# Copyright (c) 2012 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 fnmatch
+import logging
+import os
+import cPickle as pickle
+import pipes
+import re
+import subprocess
+import tempfile
+import threading
+import time
+import traceback
+import unittest
+import uuid
+from optparse import OptionParser
+from StringIO import StringIO
+
+import factory_common
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory.event import Event
+from autotest_lib.client.cros.factory import event_log
+from autotest_lib.client.cros.factory import TestState
+from autotest_lib.client.cros.factory import utils
+
+
+# Number of bytes to include from the log of a failed test.
+ERROR_LOG_TAIL_LENGTH = 8*1024
+
+
+class PyTestInfo(object):
+    def __init__(self, test_list, path, pytest_name, args, results_path):
+        self.test_list = test_list
+        self.path = path
+        self.pytest_name = pytest_name
+        self.args = args
+        self.results_path = results_path
+
+
+class TestInvocation(object):
+    '''
+    State for an active test.
+    '''
+    def __init__(self, goofy, test, on_completion=None):
+        '''Constructor.
+
+        @param goofy: The controlling Goofy object.
+        @param test: The FactoryTest object to test.
+        @param on_completion: Callback to invoke in the goofy event queue
+            on completion.
+        '''
+        self.goofy = goofy
+        self.test = test
+        self.thread = threading.Thread(target=self._run,
+                                       name='TestInvocation-%s' % test.path)
+        self.on_completion = on_completion
+        self.uuid = event_log.TimedUuid()
+        self.env_additions = {'CROS_FACTORY_TEST_PATH': self.test.path,
+                              'CROS_FACTORY_TEST_INVOCATION': self.uuid}
+        self.debug_log_path = None
+        self._lock = threading.Lock()
+        # The following properties are guarded by the lock.
+        self._aborted = False
+        self._completed = False
+        self._process = None
+
+    def __repr__(self):
+        return 'TestInvocation(_aborted=%s, _completed=%s)' % (
+            self._aborted, self._completed)
+
+    def start(self):
+        '''Starts the test thread.'''
+        self.thread.start()
+
+    def abort_and_join(self):
+        '''
+        Aborts a test (must be called from the event controller thread).
+        '''
+        with self._lock:
+            self._aborted = True
+            if self._process:
+                utils.kill_process_tree(self._process, 'autotest')
+        if self.thread:
+            self.thread.join()
+        with self._lock:
+            # Should be set by the thread itself, but just in case...
+            self._completed = True
+
+    def is_completed(self):
+        '''
+        Returns true if the test has finished.
+        '''
+        with self._lock:
+            return self._completed
+
+    def _invoke_autotest(self):
+        '''
+        Invokes an autotest test.
+
+        This method encapsulates all the magic necessary to run a single
+        autotest test using the 'autotest' command-line tool and get a
+        sane pass/fail status and error message out.  It may be better
+        to just write our own command-line wrapper for job.run_test
+        instead.
+
+        @param test: the autotest to run
+        @param dargs: the argument map
+        @return: tuple of status (TestState.PASSED or TestState.FAILED) and
+            error message, if any
+        '''
+        assert self.test.autotest_name
+
+        test_tag = '%s_%s' % (self.test.path, self.count)
+        dargs = dict(self.test.dargs)
+        dargs.update({'tag': test_tag,
+                      'test_list_path': self.goofy.options.test_list})
+
+        status = TestState.FAILED
+        error_msg = 'Unknown'
+
+        try:
+            output_dir = '%s/results/%s-%s' % (factory.CLIENT_PATH,
+                                               self.test.path,
+                                               self.uuid)
+            self.debug_log_path = os.path.join(
+                output_dir,
+                'results/default/debug/client.INFO')
+            if not os.path.exists(output_dir):
+                os.makedirs(output_dir)
+            tmp_dir = tempfile.mkdtemp(prefix='tmp', dir=output_dir)
+
+            control_file = os.path.join(tmp_dir, 'control')
+            result_file = os.path.join(tmp_dir, 'result')
+            args_file = os.path.join(tmp_dir, 'args')
+
+            with open(args_file, 'w') as f:
+                pickle.dump(dargs, f)
+
+            # Create a new control file to use to run the test
+            with open(control_file, 'w') as f:
+                print >> f, 'import common, traceback, utils'
+                print >> f, 'import cPickle as pickle'
+                print >> f, ("success = job.run_test("
+                            "'%s', **pickle.load(open('%s')))" % (
+                    self.test.autotest_name, args_file))
+
+                print >> f, (
+                    "pickle.dump((success, "
+                    "str(job.last_error) if job.last_error else None), "
+                    "open('%s', 'w'), protocol=2)"
+                    % result_file)
+
+            args = ['%s/bin/autotest' % factory.CLIENT_PATH,
+                    '--output_dir', output_dir,
+                    control_file]
+
+            logging.debug('Test command line: %s', ' '.join(
+                    [pipes.quote(arg) for arg in args]))
+
+            with self._lock:
+                with self.goofy.env.lock:
+                    self._process = self.goofy.env.spawn_autotest(
+                        self.test.autotest_name, args, self.env_additions,
+                        result_file)
+
+            returncode = self._process.wait()
+            with self._lock:
+                if self._aborted:
+                    error_msg = 'Aborted by operator'
+                    return
+
+            if returncode:
+                # Only happens when there is an autotest-level problem (not when
+                # the test actually failed).
+                error_msg = 'autotest returned with code %d' % returncode
+                return
+
+            with open(result_file) as f:
+                try:
+                    success, error_msg = pickle.load(f)
+                except:  # pylint: disable=W0702
+                    logging.exception('Unable to retrieve autotest results')
+                    error_msg = 'Unable to retrieve autotest results'
+                    return
+
+            if success:
+                status = TestState.PASSED
+                error_msg = ''
+        except Exception:  # pylint: disable=W0703
+            logging.exception('Exception in autotest driver')
+            # Make sure Goofy reports the exception upon destruction
+            # (e.g., for testing)
+            self.goofy.record_exception(traceback.format_exception_only(
+                    *sys.exc_info()[:2]))
+        finally:
+            self.clean_autotest_logs(output_dir)
+            return status, error_msg  # pylint: disable=W0150
+
+    def _invoke_pytest(self):
+        '''
+        Invokes a pyunittest-based test.
+        '''
+        assert self.test.pytest_name
+
+        files_to_delete = []
+        try:
+            def make_tmp(type):
+                ret = tempfile.mktemp(
+                    prefix='%s-%s-' % (self.test.path, type))
+                files_to_delete.append(ret)
+                return ret
+
+            info_path = make_tmp('info')
+            results_path = make_tmp('results')
+
+            log_dir = os.path.join(factory.get_log_root(),
+                                   'factory_test_logs')
+            if not os.path.exists(log_dir):
+                os.makedirs(log_dir)
+            log_path = os.path.join(log_dir,
+                                    '%s.%03d' % (self.test.path,
+                                                 self.count))
+
+            with open(info_path, 'w') as info:
+                pickle.dump(PyTestInfo(
+                        test_list=self.goofy.options.test_list,
+                        path=self.test.path,
+                        pytest_name=self.test.pytest_name,
+                        args=self.test.dargs,
+                        results_path = results_path),
+                            info)
+
+            # Invoke the unittest driver in a separate process.
+            with open(log_path, "w") as log:
+                this_file = os.path.realpath(__file__)
+                this_file = re.sub(r'\.pyc$', '.py', this_file)
+                args = [this_file, '--pytest', info_path]
+                logging.debug('Test command line: %s >& %s',
+                             ' '.join([pipes.quote(arg) for arg in args]),
+                             log_path)
+
+                env = dict(os.environ)
+                env.update(self.env_additions)
+                with self._lock:
+                    if self._aborted:
+                        return TestState.FAILED, 'Aborted before starting'
+                    self._process = subprocess.Popen(
+                        args,
+                        env=env,
+                        stdin=open(os.devnull, "w"),
+                        stdout=log,
+                        stderr=subprocess.STDOUT)
+                self._process.wait()
+                with self._lock:
+                    if self._aborted:
+                        return TestState.FAILED, 'Aborted by operator'
+                if self._process.returncode:
+                    return TestState.FAILED, (
+                        'Test returned code %d' % pytest.returncode)
+
+            if not os.path.exists(results_path):
+                return TestState.FAILED, 'pytest did not complete'
+
+            with open(results_path) as f:
+                return pickle.load(f)
+        except:  # pylint: disable=W0702
+            logging.exception('Unable to retrieve pytest results')
+            return TestState.FAILED, 'Unable to retrieve pytest results'
+        finally:
+            for f in files_to_delete:
+                try:
+                    if os.path.exists(f):
+                        os.unlink(f)
+                except:
+                    logging.exception('Unable to delete temporary file %s',
+                                      f)
+
+    def clean_autotest_logs(self, output_dir):
+        globs = self.goofy.test_list.options.preserve_autotest_results
+        if '*' in globs:
+            # Keep everything
+            return
+
+        deleted_count = 0
+        preserved_count = 0
+        for root, dirs, files in os.walk(output_dir, topdown=False):
+            for f in files:
+                if any(fnmatch.fnmatch(f, g)
+                       for g in globs):
+                    # Keep it
+                    preserved_count = 1
+                else:
+                    try:
+                        os.unlink(os.path.join(root, f))
+                        deleted_count += 1
+                    except:
+                        logging.exception('Unable to remove %s' %
+                                          os.path.join(root, f))
+            try:
+                # Try to remove the directory (in case it's empty now)
+                os.rmdir(root)
+            except:
+                # Not empty; that's OK
+                pass
+        logging.info('Preserved %d files matching %s and removed %d',
+                     preserved_count, globs, deleted_count)
+
+    def _run(self):
+        with self._lock:
+            if self._aborted:
+                return
+
+        self.count = self.test.update_state(
+            status=TestState.ACTIVE, increment_count=1, error_msg='',
+            invocation=self.uuid).count
+
+        factory.console.info('Running test %s' % self.test.path)
+
+        log_args = dict(
+            path=self.test.path,
+            # Use Python representation for dargs, since some elements
+            # may not be representable in YAML.
+            dargs=repr(self.test.dargs),
+            invocation=self.uuid)
+        if self.test.autotest_name:
+            log_args['autotest_name'] = self.test.autotest_name
+        if self.test.pytest_name:
+            log_args['pytest_name'] = self.test.pytest_name
+
+        self.goofy.event_log.Log('start_test', **log_args)
+        start_time = time.time()
+        try:
+            if self.test.autotest_name:
+                status, error_msg = self._invoke_autotest()
+            elif self.test.pytest_name:
+                status, error_msg = self._invoke_pytest()
+            else:
+                status = TestState.FAILED
+                error_msg = 'No autotest_name or pytest_name'
+        finally:
+            try:
+                self.goofy.event_client.post_event(
+                    Event(Event.Type.DESTROY_TEST,
+                          test=self.test.path,
+                          invocation=self.uuid))
+            except:
+                logging.exception('Unable to post END_TEST event')
+
+            try:
+                # Leave all items in log_args; this duplicates
+                # things but will make it easier to grok the output.
+                log_args.update(dict(status=status,
+                                     duration=time.time() - start_time))
+                if error_msg:
+                    log_args['error_msg'] = error_msg
+                if (status != TestState.PASSED and
+                    self.debug_log_path and
+                    os.path.exists(self.debug_log_path)):
+                    try:
+                        debug_log_size = os.path.getsize(self.debug_log_path)
+                        offset = max(0, debug_log_size - ERROR_LOG_TAIL_LENGTH)
+                        with open(self.debug_log_path) as f:
+                            f.seek(offset)
+                            log_args['log_tail'] = f.read()
+                    except:
+                        logging.exception('Unable to read log tail')
+                self.goofy.event_log.Log('end_test', **log_args)
+            except:
+                logging.exception('Unable to log end_test event')
+
+        factory.console.info('Test %s %s%s',
+                             self.test.path,
+                             status,
+                             ': %s' % error_msg if error_msg else '')
+
+        self.test.update_state(status=status, error_msg=error_msg,
+                               visible=False)
+        with self._lock:
+            self._completed = True
+
+        self.goofy.run_queue.put(self.goofy.reap_completed_tests)
+        if self.on_completion:
+            self.goofy.run_queue.put(self.on_completion)
+
+
+def run_pytest(test_info):
+    '''Runs a pytest, saving a pickled (status, error_msg) tuple to the
+    appropriate results file.
+
+    Args:
+        test_info: A PyTestInfo object containing information about what to
+            run.
+    '''
+    try:
+        __import__('autotest_lib.client.cros.factory.tests.%s' %
+                   test_info.pytest_name)
+        module = getattr(factory.tests, test_info.pytest_name)
+        suite = unittest.TestLoader().loadTestsFromModule(module)
+
+        # Recursively set
+        def set_test_info(test):
+            if isinstance(test, unittest.TestCase):
+                test.test_info = test_info
+            elif isinstance(test, unittest.TestSuite):
+                for x in test:
+                    set_test_info(x)
+        set_test_info(suite)
+
+        runner = unittest.TextTestRunner()
+        result = runner.run(suite)
+
+        def format_error_msg(test_name, trace):
+            '''Formats a trace so that the actual error message is in the last
+            line.
+            '''
+            # The actual error is in the last line.
+            trace, _, error_msg = trace.strip().rpartition('\n')
+            error_msg = error_msg.replace('FactoryTestFailure: ', '')
+            return error_msg + '\n' + trace
+
+        all_failures = result.failures + result.errors
+        if all_failures:
+            status = TestState.FAILED
+            error_msg = '; '.join(format_error_msg(test_name, trace)
+                                  for test_name, trace in all_failures)
+            logging.info('pytest failure: %s', error_msg)
+        else:
+            status = TestState.PASSED
+            error_msg = ''
+    except:
+        logging.exception('Unable to run pytest')
+        status = TestState.FAILED
+        error_msg = traceback.format_exc()
+
+    with open(test_info.results_path, 'w') as results:
+        pickle.dump((status, error_msg), results)
+
+def main():
+    parser = OptionParser()
+    parser.add_option('--pytest', dest='pytest_info',
+                      help='Info for pytest to run')
+    (options, args) = parser.parse_args()
+
+    assert options.pytest_info
+
+    info = pickle.load(open(options.pytest_info))
+    factory.init_logging(info.path)
+    run_pytest(info)
+
+if __name__ == '__main__':
+    main()
diff --git a/py/goofy/js/goofy.js b/py/goofy/js/goofy.js
new file mode 100644
index 0000000..89e00ae
--- /dev/null
+++ b/py/goofy/js/goofy.js
@@ -0,0 +1,1392 @@
+// Copyright (c) 2012 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.
+
+goog.provide('cros.factory.Goofy');
+
+goog.require('goog.crypt');
+goog.require('goog.crypt.Sha1');
+goog.require('goog.debug.ErrorHandler');
+goog.require('goog.debug.FancyWindow');
+goog.require('goog.debug.Logger');
+goog.require('goog.dom');
+goog.require('goog.dom.classes');
+goog.require('goog.dom.iframe');
+goog.require('goog.events');
+goog.require('goog.events.EventHandler');
+goog.require('goog.i18n.NumberFormat');
+goog.require('goog.json');
+goog.require('goog.math');
+goog.require('goog.net.WebSocket');
+goog.require('goog.net.XhrIo');
+goog.require('goog.string');
+goog.require('goog.style');
+goog.require('goog.Uri');
+goog.require('goog.ui.AdvancedTooltip');
+goog.require('goog.ui.Checkbox');
+goog.require('goog.ui.Dialog');
+goog.require('goog.ui.Dialog.ButtonSet');
+goog.require('goog.ui.MenuSeparator');
+goog.require('goog.ui.PopupMenu');
+goog.require('goog.ui.ProgressBar');
+goog.require('goog.ui.Prompt');
+goog.require('goog.ui.Select');
+goog.require('goog.ui.SplitPane');
+goog.require('goog.ui.tree.TreeControl');
+
+cros.factory.logger = goog.debug.Logger.getLogger('cros.factory');
+
+/**
+ * @define {boolean} Whether to automatically collapse items once tests have
+ *     completed.
+ */
+cros.factory.AUTO_COLLAPSE = false;
+
+/**
+ * Keep-alive interval for the WebSocket.  (Chrome times out
+ * WebSockets every ~1 min, so 30 s seems like a good interval.)
+ * @const
+ * @type number
+ */
+cros.factory.KEEP_ALIVE_INTERVAL_MSEC = 30000;
+
+/**
+ * Interval at which to update system status.
+ * @const
+ * @type number
+ */
+cros.factory.SYSTEM_STATUS_INTERVAL_MSEC = 5000;
+
+/**
+ * Width of the control panel, as a fraction of the viewport size.
+ * @type number
+ */
+cros.factory.CONTROL_PANEL_WIDTH_FRACTION = 0.2;
+
+/**
+ * Minimum width of the control panel, in pixels.
+ * @type number
+ */
+cros.factory.CONTROL_PANEL_MIN_WIDTH = 275;
+
+/**
+ * Height of the log pane, as a fraction of the viewport size.
+ * @type number
+ */
+cros.factory.LOG_PANE_HEIGHT_FRACTION = 0.2;
+
+/**
+ * Minimum height of the log pane, in pixels.
+ * @type number
+ */
+cros.factory.LOG_PANE_MIN_HEIGHT = 170;
+
+/**
+ * Makes a label that displays English (or optionally Chinese).
+ * @param {string} en
+ * @param {string=} zh
+ */
+cros.factory.Label = function(en, zh) {
+    return '<span class="goofy-label-en">' + en + '</span>' +
+      '<span class="goofy-label-zh">' + (zh || en) + '</span>';
+};
+
+/**
+ * Makes control content that displays English (or optionally Chinese).
+ * @param {string} en
+ * @param {string=} zh
+ * @return {Node}
+ */
+cros.factory.Content = function(en, zh) {
+    var span = document.createElement('span');
+    span.innerHTML = cros.factory.Label(en, zh);
+    return span;
+};
+
+/**
+ * Labels for items in system info.
+ * @type Array.<Object.<string, string>>
+ */
+cros.factory.SYSTEM_INFO_LABELS = [
+    {key: 'serial_number', label: cros.factory.Label('Serial Number')},
+    {key: 'factory_image_version',
+     label: cros.factory.Label('Factory Image Version')},
+    {key: 'wlan0_mac', label: cros.factory.Label('WLAN MAC')},
+    {key: 'kernel_version', label: cros.factory.Label('Kernel')},
+    {key: 'ec_version', label: cros.factory.Label('EC')},
+    {key: 'firmware_version', label: cros.factory.Label('Firmware')},
+    {key: 'factory_md5sum', label: cros.factory.Label('Factory MD5SUM'),
+     transform: function(value) {
+            return value || cros.factory.Label('(no update)')
+        }}
+                                   ];
+
+cros.factory.UNKNOWN_LABEL = '<span class="goofy-unknown">' +
+    cros.factory.Label('Unknown') + '</span>';
+
+/**
+ * An item in the test list.
+ * @typedef {{path: string, label_en: string, label_zh: string,
+ *            kbd_shortcut: string, subtests: Array}}
+ */
+cros.factory.TestListEntry;
+
+/**
+ * A pending shutdown event.
+ * @typedef {{delay_secs: number, time: number, operation: string,
+ *            iteration: number, iterations: number }}
+ */
+cros.factory.PendingShutdownEvent;
+
+/**
+ * Public API for tests.
+ * @constructor
+ * @param {cros.factory.Invocation} invocation
+ */
+cros.factory.Test = function(invocation) {
+    /**
+     * @type cros.factory.Invocation
+     */
+    this.invocation = invocation;
+};
+
+/**
+ * Passes the test.
+ * @export
+ */
+cros.factory.Test.prototype.pass = function() {
+    this.invocation.goofy.sendEvent(
+        'goofy:end_test', {
+            'status': 'PASSED',
+            'invocation': this.invocation.uuid,
+            'test': this.invocation.path
+        });
+    this.invocation.dispose();
+};
+
+/**
+ * Fails the test with the given error message.
+ * @export
+ * @param {string} errorMsg
+ */
+cros.factory.Test.prototype.fail = function(errorMsg) {
+    this.invocation.goofy.sendEvent('goofy:end_test', {
+            'status': 'FAILED',
+            'error_msg': errorMsg,
+            'invocation': this.invocation.uuid,
+            'test': this.invocation.path
+        });
+    this.invocation.dispose();
+};
+
+/**
+ * Sends an event to the test backend.
+ * @export
+ * @param {string} subtype the event type
+ * @param {string} data the event data
+ */
+cros.factory.Test.prototype.sendTestEvent = function(subtype, data) {
+    this.invocation.goofy.sendEvent('goofy:test_ui_event', {
+        'test': this.invocation.path,
+        'invocation': this.invocation.uuid,
+        'subtype': subtype,
+        'data': data
+        });
+};
+
+/**
+ * UI for a single test invocation.
+ * @constructor
+ * @param {cros.factory.Goofy} goofy
+ * @param {string} path
+ */
+cros.factory.Invocation = function(goofy, path, uuid) {
+    /**
+     * Reference to the Goofy object.
+     * @type cros.factory.Goofy
+     */
+    this.goofy = goofy;
+
+    /**
+     * @type string
+     */
+    this.path = path;
+
+    /**
+     * UUID of the invocation.
+     * @type string
+     */
+    this.uuid = uuid;
+
+    /**
+     * Test API for the invocation.
+     */
+    this.test = new cros.factory.Test(this);
+
+    /**
+     * The iframe containing the test.
+     * @type HTMLIFrameElement
+     */
+    this.iframe = goog.dom.iframe.createBlank(new goog.dom.DomHelper(document));
+    document.getElementById('goofy-main').appendChild(this.iframe);
+    this.iframe.contentWindow.test = this.test;
+};
+
+/**
+ * Disposes of the invocation (and destroys the iframe).
+ */
+cros.factory.Invocation.prototype.dispose = function() {
+    if (this.iframe) {
+        goog.dom.removeNode(this.iframe);
+        this.goofy.invocations[this.uuid] = null;
+        this.iframe = null;
+    }
+};
+
+/**
+ * The main Goofy UI.
+ *
+ * @constructor
+ */
+cros.factory.Goofy = function() {
+    /**
+     * The WebSocket we'll use to communicate with the backend.
+     * @type goog.net.WebSocket
+     */
+    this.ws = new goog.net.WebSocket();
+
+    /**
+     * Whether we have opened the WebSocket yet.
+     * @type boolean
+     */
+    this.wsOpened = false;
+
+    /**
+     * The UUID that we received from Goofy when starting up.
+     * @type {?string}
+     */
+    this.uuid = null;
+
+    /**
+     * Whether the context menu is currently visible.
+     * @type boolean
+     */
+    this.contextMenuVisible = false;
+
+    /**
+     * All tooltips that we have created.
+     * @type Array.<goog.ui.AdvancedTooltip>
+     */
+    this.tooltips = [];
+
+    /**
+     * The test tree.
+     */
+    this.testTree = new goog.ui.tree.TreeControl('Tests');
+    this.testTree.setShowRootNode(false);
+    this.testTree.setShowLines(false);
+
+    /**
+     * A map from test path to the tree node for each test.
+     * @type Object.<string, goog.ui.tree.BaseNode>
+     */
+    this.pathNodeMap = new Object();
+
+    /**
+     * A map from test path to the entry in the test list for that test.
+     * @type Object.<string, cros.factory.TestListEntry>
+     */
+    this.pathTestMap = new Object();
+
+    /**
+     * Whether Chinese mode is currently enabled.
+     *
+     * TODO(jsalz): Generalize this to multiple languages (but this isn't
+     * really necessary now).
+     *
+     * @type boolean
+     */
+    this.zhMode = false;
+
+    /**
+     * The tooltip for version number information.
+     */
+    this.infoTooltip = new goog.ui.AdvancedTooltip(
+        document.getElementById('goofy-system-info-hover'));
+    this.infoTooltip.setHtml('Version information not yet available.');
+
+    /**
+     * UIs for individual test invocations (by UUID).
+     * @type Object.<string, cros.factory.Invocation>
+     */
+    this.invocations = {};
+
+    /**
+     * Eng mode prompt.
+     * @type goog.ui.Dialog
+     */
+    this.engineeringModeDialog = null;
+
+    /**
+     * Shutdown prompt dialog.
+     * @type goog.ui.Dialog
+     */
+    this.shutdownDialog = null;
+
+    /**
+     * Whether eng mode is enabled.
+     * @type {boolean}
+     */
+    this.engineeringMode = false;
+
+    /**
+     * SHA1 hash of password to take UI out of operator mode.  If
+     * null, eng mode is always enabled.  Defaults to an invalid '?',
+     * which means that eng mode cannot be entered (will be set from
+     * Goofy's shared_data).
+     * @type {?string}
+     */
+    this.engineeringPasswordSHA1 = '?';
+
+    var debugWindow = new goog.debug.FancyWindow('main');
+    debugWindow.setEnabled(false);
+    debugWindow.init();
+    // Magic keyboard shortcuts.
+    goog.events.listen(
+        window, goog.events.EventType.KEYDOWN,
+        function(event) {
+            if (event.altKey && event.ctrlKey) {
+                switch (String.fromCharCode(event.keyCode)) {
+                case '0':
+                    this.promptEngineeringPassword();
+                    break;
+                case '1':
+                    debugWindow.setEnabled(true);
+                    break;
+                default:
+                    // Nothing
+                }
+            }
+        }, false, this);
+};
+
+/**
+ * Initializes the split panes.
+ */
+cros.factory.Goofy.prototype.initSplitPanes = function() {
+    var viewportSize = goog.dom.getViewportSize(goog.dom.getWindow(document));
+    var mainComponent = new goog.ui.Component();
+    var consoleComponent = new goog.ui.Component();
+    var mainAndConsole = new goog.ui.SplitPane(
+        mainComponent, consoleComponent,
+        goog.ui.SplitPane.Orientation.VERTICAL);
+    mainAndConsole.setInitialSize(
+        viewportSize.height -
+        Math.max(cros.factory.LOG_PANE_MIN_HEIGHT,
+                 1 - cros.factory.LOG_PANE_HEIGHT_FRACTION));
+
+    var controlComponent = new goog.ui.Component();
+    var topSplitPane = new goog.ui.SplitPane(
+        controlComponent, mainAndConsole,
+        goog.ui.SplitPane.Orientation.HORIZONTAL);
+    topSplitPane.setInitialSize(
+        Math.max(cros.factory.CONTROL_PANEL_MIN_WIDTH,
+                 viewportSize.width *
+                 cros.factory.CONTROL_PANEL_WIDTH_FRACTION));
+    topSplitPane.decorate(document.getElementById('goofy-splitpane'));
+
+    mainComponent.getElement().id = 'goofy-main';
+    consoleComponent.getElement().id = 'goofy-console';
+    this.console = consoleComponent.getElement();
+    this.main = mainComponent.getElement();
+
+    var propagate = true;
+    goog.events.listen(
+        topSplitPane, goog.ui.Component.EventType.CHANGE,
+        function(event) {
+            if (!propagate) {
+                // Prevent infinite recursion
+                return;
+            }
+
+            propagate = false;
+            mainAndConsole.setFirstComponentSize(
+                mainAndConsole.getFirstComponentSize());
+            propagate = true;
+
+            var rect = mainComponent.getElement().getBoundingClientRect();
+            this.sendRpc('get_shared_data', ['ui_scale_factor'],
+                         function(uiScaleFactor) {
+                             this.sendRpc('set_shared_data',
+                                          ['test_widget_size',
+                                           [rect.width * uiScaleFactor,
+                                            rect.height * uiScaleFactor],
+                                           'test_widget_position',
+                                           [rect.left * uiScaleFactor,
+                                            rect.top * uiScaleFactor]]);
+                         });
+        }, false, this);
+    mainAndConsole.setFirstComponentSize(
+        mainAndConsole.getFirstComponentSize());
+    goog.events.listen(
+        window, goog.events.EventType.RESIZE,
+        function(event) {
+            topSplitPane.setSize(
+                goog.dom.getViewportSize(goog.dom.getWindow(document) ||
+                                         window));
+        });
+
+    function onKey(e) {
+        if (e.keyCode == goog.events.KeyCodes.ESC) {
+            this.sendEvent('goofy:cancel_shutdown', {});
+            // Wait for Goofy to reset the pending_shutdown data.
+        }
+    }
+}
+
+/**
+ * Initializes the WebSocket.
+ */
+cros.factory.Goofy.prototype.initWebSocket = function() {
+    goog.events.listen(this.ws, goog.net.WebSocket.EventType.OPENED,
+                       function(event) {
+                           this.logInternal('Connection to Goofy opened.');
+                           this.wsOpened = true;
+                       }, false, this);
+    goog.events.listen(this.ws, goog.net.WebSocket.EventType.ERROR,
+                       function(event) {
+                           this.logInternal('Error connecting to Goofy.');
+                       }, false, this);
+    goog.events.listen(this.ws, goog.net.WebSocket.EventType.CLOSED,
+                       function(event) {
+                           if (this.wsOpened) {
+                               this.logInternal('Connection to Goofy closed.');
+                               this.wsOpened = false;
+                           }
+                       }, false, this);
+    goog.events.listen(this.ws, goog.net.WebSocket.EventType.MESSAGE,
+                       function(event) {
+                           this.handleBackendEvent(event.message);
+                       }, false, this);
+    window.setInterval(goog.bind(this.keepAlive, this),
+                       cros.factory.KEEP_ALIVE_INTERVAL_MSEC);
+    window.setInterval(goog.bind(this.updateStatus, this),
+                       cros.factory.SYSTEM_STATUS_INTERVAL_MSEC);
+    this.updateStatus();
+    this.ws.open("ws://" + window.location.host + "/event");
+};
+
+/**
+ * Starts the UI.
+ */
+cros.factory.Goofy.prototype.init = function() {
+    this.initLanguageSelector();
+    this.initSplitPanes();
+
+    // Listen for keyboard shortcuts.
+    goog.events.listen(
+        window, goog.events.EventType.KEYDOWN,
+        function(event) {
+            if (event.altKey || event.ctrlKey) {
+                this.handleShortcut(String.fromCharCode(event.keyCode));
+            }
+        }, false, this);
+
+    this.initWebSocket();
+    this.sendRpc('get_test_list', [], this.setTestList);
+    this.sendRpc('get_shared_data', ['system_info'], this.setSystemInfo);
+    this.sendRpc(
+        'get_shared_data', ['test_list_options'],
+            function(options) {
+                this.engineeringPasswordSHA1 =
+                    options['engineering_password_sha1'];
+                // If no password, enable eng mode, and don't
+                // show the 'disable' link, since there is no way to
+                // enable it.
+                goog.style.showElement(document.getElementById(
+                    'goofy-disable-engineering-mode'),
+                    this.engineeringPasswordSHA1 != null);
+                this.setEngineeringMode(this.engineeringPasswordSHA1 == null);
+            });
+};
+
+/**
+ * Sets up the language selector.
+ */
+cros.factory.Goofy.prototype.initLanguageSelector = function() {
+    goog.events.listen(
+        document.getElementById('goofy-language-selector'),
+        goog.events.EventType.CLICK,
+        function(event) {
+            this.zhMode = !this.zhMode;
+            this.updateLanguage();
+            this.sendRpc('set_shared_data',
+                         ['ui_lang', this.zhMode ? 'zh' : 'en']);
+        }, false, this);
+
+    this.updateLanguage();
+    this.sendRpc('get_shared_data', ['ui_lang'], function(lang) {
+            this.zhMode = lang == 'zh';
+            this.updateLanguage();
+        });
+};
+
+/**
+ * Gets an invocation for a test (creating it if necessary).
+ *
+ * @param {string} path
+ * @param {string} invocationUuid
+ * @return the invocation, or null if the invocation has already been created
+ *     and deleted.
+ */
+cros.factory.Goofy.prototype.getOrCreateInvocation = function(
+    path, invocationUuid) {
+    if (!(invocationUuid in this.invocations)) {
+        cros.factory.logger.info('Creating UI for test ' + path +
+                                 ' (invocation ' + invocationUuid);
+        this.invocations[invocationUuid] =
+            new cros.factory.Invocation(this, path, invocationUuid);
+    }
+    return this.invocations[invocationUuid];
+};
+
+/**
+ * Updates language classes in the UI based on the current value of
+ * zhMode.
+ */
+cros.factory.Goofy.prototype.updateLanguage = function() {
+    goog.dom.classes.enable(document.body, 'goofy-lang-en', !this.zhMode);
+    goog.dom.classes.enable(document.body, 'goofy-lang-zh', this.zhMode);
+}
+
+/**
+ * Updates the system info tooltip.
+ * @param systemInfo Object.<string, string>
+ */
+cros.factory.Goofy.prototype.setSystemInfo = function(systemInfo) {
+    var table = [];
+    table.push('<table id="goofy-system-info">');
+    goog.array.forEach(cros.factory.SYSTEM_INFO_LABELS, function(item) {
+            var value = systemInfo[item.key];
+            var html;
+            if (item.transform) {
+                html = item.transform(value);
+            } else {
+                html = value == undefined ?
+                    cros.factory.UNKNOWN_LABEL :
+                    goog.string.htmlEscape(value);
+            }
+            table.push(
+                       '<tr><th>' + item.label + '</th><td>' + html +
+                       '</td></tr>');
+        });
+    table.push('</table>');
+    this.infoTooltip.setHtml(table.join(''));
+};
+
+/**
+ * Displays an alert.
+ * @param {string} messageHtml
+ */
+cros.factory.Goofy.prototype.alert = function(messageHtml) {
+    var dialog = new goog.ui.Dialog();
+    dialog.setTitle('Alert');
+    dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk());
+    dialog.setContent(messageHtml);
+    dialog.setVisible(true);
+    goog.dom.classes.add(dialog.getElement(), 'goofy-alert');
+    this.positionOverConsole(dialog.getElement());
+};
+
+/**
+ * Centers an element over the console.
+ * @param {Element} element
+ */
+cros.factory.Goofy.prototype.positionOverConsole = function(element) {
+    var consoleBounds = goog.style.getBounds(this.console.parentNode);
+    var size = goog.style.getSize(element);
+    goog.style.setPosition(
+        element,
+        consoleBounds.left + consoleBounds.width/2 - size.width/2,
+        consoleBounds.top + consoleBounds.height/2 - size.height/2);
+};
+
+/**
+ * Prompts to enter eng mode.
+ */
+cros.factory.Goofy.prototype.promptEngineeringPassword = function() {
+    if (this.engineeringModeDialog) {
+        this.engineeringModeDialog.setVisible(false);
+        this.engineeringModeDialog.dispose();
+        this.engineeringModeDialog = null;
+    }
+    if (!this.engineeringPasswordSHA1) {
+        this.alert('No password has been set.');
+        return;
+    }
+    if (this.engineeringMode) {
+        this.setEngineeringMode(false);
+        return;
+    }
+
+    this.engineeringModeDialog = new goog.ui.Prompt(
+        'Password', '',
+        goog.bind(function(text) {
+            if (!text || text == '') {
+                return;
+            }
+            var hash = new goog.crypt.Sha1();
+            hash.update(text);
+            var digest = goog.crypt.byteArrayToHex(hash.digest());
+            if (digest == this.engineeringPasswordSHA1) {
+                this.setEngineeringMode(true);
+            } else {
+                this.alert('Incorrect password.');
+            }
+        }, this));
+    this.engineeringModeDialog.setVisible(true);
+    goog.dom.classes.add(this.engineeringModeDialog.getElement(),
+                         'goofy-engineering-mode-dialog');
+    this.engineeringModeDialog.reposition();
+    this.positionOverConsole(this.engineeringModeDialog.getElement());
+};
+
+/**
+ * Sets eng mode.
+ * @param {boolean} enabled
+ */
+cros.factory.Goofy.prototype.setEngineeringMode = function(enabled) {
+    this.engineeringMode = enabled;
+    goog.dom.classes.enable(document.body, 'goofy-engineering-mode', enabled);
+};
+
+/**
+ * Closes any open dialog.
+ */
+cros.factory.Goofy.prototype.closeDialog = function() {
+    if (this.dialog) {
+        this.dialog.setVisible(false);
+        this.dialog.dispose();
+        this.dialog = null;
+    }
+};
+
+/**
+ * Deals with data about a pending reboot.
+ * @param {cros.factory.PendingShutdownEvent} shutdownInfo
+ */
+cros.factory.Goofy.prototype.setPendingShutdown = function(shutdownInfo) {
+    if (this.shutdownDialog) {
+        this.shutdownDialog.setVisible(false);
+        this.shutdownDialog.dispose();
+        this.shutdownDialog = null;
+    }
+    if (!shutdownInfo || !shutdownInfo.time) {
+        return;
+    }
+    this.closeDialog();
+
+    var verbEn = shutdownInfo.operation == 'reboot' ?
+        'Rebooting' : 'Shutting down';
+    var verbZh = shutdownInfo.operation == 'reboot' ? '重開機' : '關機';
+
+    var timesEn = shutdownInfo.iterations == 1 ? 'once' : (
+        shutdownInfo.iteration + ' of ' + shutdownInfo.iterations + ' times');
+    var timesZh = shutdownInfo.iterations == 1 ? '1次' : (
+        shutdownInfo.iterations + '次' + verbZh + '測試中的第' +
+        shutdownInfo.iteration + '次');
+
+    this.shutdownDialog = new goog.ui.Dialog();
+    this.shutdownDialog.setContent(
+        '<p>' + verbEn + ' in <span class="goofy-shutdown-secs"></span> ' +
+        'second<span class="goofy-shutdown-secs-plural"></span> (' + timesEn +
+        ').<br>' +
+        'To cancel, press the Escape key.</p>' +
+        '<p>將會在<span class="goofy-shutdown-secs"></span>秒內' + verbZh +
+        '(' + timesZh + ').<br>按ESC鍵取消.</p>');
+
+    var progressBar = new goog.ui.ProgressBar();
+    progressBar.render(this.shutdownDialog.getContentElement());
+
+    function tick() {
+        var now = new Date().getTime() / 1000.0;
+
+        var startTime = shutdownInfo.time - shutdownInfo.delay_secs;
+        var endTime = shutdownInfo.time;
+        var fraction = (now - startTime) / (endTime - startTime);
+        progressBar.setValue(goog.math.clamp(fraction, 0, 1) * 100);
+
+        var secondsLeft = 1 + Math.floor(Math.max(0, endTime - now));
+        goog.array.forEach(
+            goog.dom.getElementsByClass('goofy-shutdown-secs'), function(elt) {
+                elt.innerHTML = secondsLeft;
+            }, this);
+        goog.array.forEach(
+            goog.dom.getElementsByClass('goofy-shutdown-secs-plural'),
+            function(elt) {
+                elt.innerHTML = secondsLeft == 1 ? '' : 's';
+            }, this);
+    }
+
+    var timer = new goog.Timer(20);
+    goog.events.listen(timer, goog.Timer.TICK, tick, false, this);
+    timer.start();
+
+    goog.events.listen(this.shutdownDialog, goog.ui.Component.EventType.HIDE,
+                       function(event) {
+                           timer.dispose();
+                       }, false, this);
+
+    function onKey(e) {
+        if (e.keyCode == goog.events.KeyCodes.ESC) {
+            this.sendEvent('goofy:cancel_shutdown', {});
+            // Wait for Goofy to reset the pending_shutdown data.
+        }
+    }
+    goog.events.listen(this.shutdownDialog.getElement(),
+                       goog.events.EventType.KEYDOWN, onKey, false, this);
+
+    this.shutdownDialog.setButtonSet(null);
+    this.shutdownDialog.setHasTitleCloseButton(false);
+    this.shutdownDialog.setEscapeToCancel(false);
+    goog.dom.classes.add(this.shutdownDialog.getElement(),
+                         'goofy-shutdown-dialog');
+    this.shutdownDialog.setVisible(true);
+};
+
+/**
+ * Handles a keyboard shortcut.
+ * @param {string} key the key that was depressed (e.g., 'a' for Alt-A).
+ */
+cros.factory.Goofy.prototype.handleShortcut = function(key) {
+    for (var path in this.pathTestMap) {
+        var test = this.pathTestMap[path];
+        if (test.kbd_shortcut &&
+            test.kbd_shortcut.toLowerCase() == key.toLowerCase()) {
+            this.sendEvent('goofy:restart_tests', {path: path});
+            return;
+        }
+    }
+};
+
+/**
+ * Makes a menu item for a context-sensitive menu.
+ *
+ * TODO(jsalz): Figure out the correct logic for this and how to localize this.
+ * (Please just consider this a rough cut for now!)
+ *
+ * @param {string} verbEn the action in English.
+ * @param {string} verbZh the action in Chinese.
+ * @param {string} adjectiveEn a descriptive adjective for the tests (e.g.,
+ *     'failed').
+ * @param {string} adjectiveZh the adjective in Chinese.
+ * @param {number} count the number of tests.
+ * @param {cros.factory.TestListEntry} test the name of the root node containing
+ *     the tests.
+ * @param {Object} handler the handler function (see goog.events.listen).
+ * @param {boolean=} opt_adjectiveAtEnd put the adjective at the end in English
+ *     (e.g., tests that have *not passed*)
+ */
+cros.factory.Goofy.prototype.makeMenuItem = function(
+    verbEn, verbZh, adjectiveEn, adjectiveZh, count, test, handler,
+    opt_adjectiveAtEnd) {
+
+    var labelEn = verbEn + ' ';
+    var labelZh = verbZh;
+    if (!test.subtests.length) {
+        // leaf node
+        labelEn += (opt_adjectiveAtEnd ? '' : adjectiveEn) +
+            ' test ' + test.label_en;
+        labelZh += adjectiveZh + '測試';
+    } else {
+        labelEn += count + ' ' + (opt_adjectiveAtEnd ? '' : adjectiveEn) + ' ' +
+            (count == 1 ? 'test' : 'tests');
+        if (test.label_en) {
+            labelEn += ' in "' + goog.string.htmlEscape(test.label_en) + '"';
+        }
+
+        labelZh += count + '個' + adjectiveZh;
+        if (test.label_en || test.label_zh) {
+            labelZh += ('在“' +
+                goog.string.htmlEscape(test.label_en || test.label_zh) +
+                '”裡面的');
+        }
+        labelZh += '測試';
+    }
+
+    if (opt_adjectiveAtEnd) {
+        labelEn += ' that ' + (count == 1 ? 'has' : 'have') + ' not passed';
+    }
+
+    var item = new goog.ui.MenuItem(cros.factory.Content(labelEn, labelZh));
+    item.setEnabled(count != 0);
+    goog.events.listen(item, goog.ui.Component.EventType.ACTION,
+                       handler, true, this);
+    return item;
+};
+
+/**
+ * Displays test logs in a modal dialog.
+ * @param {Array.<string>} paths paths whose logs should be displayed.
+ *    (The first entry should be the root; its name will be used as the
+ *    title.)
+ */
+cros.factory.Goofy.prototype.showTestLogs = function(paths) {
+    this.sendRpc('get_test_history', [paths], function(history) {
+        var dialog = new goog.ui.Dialog();
+
+        if (history.length) {
+            var viewSize = goog.dom.getViewportSize(
+                goog.dom.getWindow(document) || window);
+            var maxWidth = viewSize.width * 0.75;
+            var maxHeight = viewSize.height * 0.75;
+
+            var content = [
+                '<dl class="goofy-history" style="max-width: ' +
+                maxWidth + 'px; max-height: ' + maxHeight + 'px">'
+                           ];
+            goog.array.forEach(history, function(item) {
+                content.push('<dt class="goofy-history-item history-item-' +
+                             item.state.status +
+                             '">' + goog.string.htmlEscape(item.path) +
+                             ' (run ' +
+                             item.state.count + ')</dt>');
+                content.push('<dd>' + goog.string.htmlEscape(item.log) +
+                             '</dd>');
+            }, this);
+            content.push('</dl>');
+            dialog.setContent(content.join(''));
+        } else {
+            dialog.setContent('<div class="goofy-history-none">' +
+                              'No test runs have completed yet.</div>');
+        }
+        dialog.setTitle(
+            'Logs for ' + (paths[0] == '' ? 'all tests' :
+                           '"' + goog.string.htmlEscape(paths[0]) + '"'));
+        dialog.setButtonSet(goog.ui.Dialog.ButtonSet.createOk())
+        dialog.setVisible(true);
+    });
+};
+
+/**
+ * Displays a context menu for a test in the test tree.
+ * @param {string} path the path of the test whose context menu should be
+ *     displayed.
+ * @param {Element} labelElement the label element of the node in the test
+ *     tree.
+ * @param {Array.<goog.ui.Control>=} extraItems items to prepend to the
+ *     menu.
+ */
+cros.factory.Goofy.prototype.showTestPopup = function(path, labelElement,
+                                                      extraItems) {
+    this.contextMenuVisible = true;
+    // Hide all tooltips so that they don't fight with the context menu.
+    goog.array.forEach(this.tooltips, function(tooltip) {
+            tooltip.setVisible(false);
+        });
+
+    var menu = new goog.ui.PopupMenu();
+
+    if (extraItems && extraItems.length) {
+        goog.array.forEach(extraItems, function(item) {
+                menu.addChild(item, true);
+            }, this);
+        menu.addChild(new goog.ui.MenuSeparator(), true);
+    }
+
+    var numLeaves = 0;
+    var numLeavesByStatus = {};
+    var test = this.pathTestMap[path];
+    var allPaths = [];
+    function countLeaves(test) {
+        allPaths.push(test.path);
+        goog.array.forEach(test.subtests, function(subtest) {
+                countLeaves(subtest);
+            }, this);
+
+        if (!test.subtests.length) {
+            ++numLeaves;
+            numLeavesByStatus[test.state.status] = 1 + (
+                numLeavesByStatus[test.state.status] || 0);
+        }
+    }
+    countLeaves(test);
+
+    var restartOrRunEn = numLeavesByStatus['UNTESTED'] == numLeaves ?
+        'Run' : 'Restart';
+    var restartOrRunZh = numLeavesByStatus['UNTESTED'] == numLeaves ?
+        '執行' : '重跑';
+    if (numLeaves > 1) {
+        restartOrRunEn += ' all';
+        restartOrRunZh += '所有的';
+    }
+    menu.addChild(this.makeMenuItem(restartOrRunEn, restartOrRunZh,
+                                    '', '',
+                                    numLeaves, test,
+                                    function(event) {
+        this.sendEvent('goofy:restart_tests', {'path': path});
+    }), true);
+    if (test.subtests.length) {
+        // Only show for parents.
+        menu.addChild(this.makeMenuItem(
+            'Restart', '重跑', 'not passed', '未成功',
+            (numLeavesByStatus['UNTESTED'] || 0) +
+            (numLeavesByStatus['ACTIVE'] || 0) +
+            (numLeavesByStatus['FAILED'] || 0),
+            test, function(event) {
+                this.sendEvent('goofy:run_tests_with_status', {
+                        'status': ['UNTESTED', 'ACTIVE', 'FAILED'],
+                        'path': path
+                    });
+            }, /*opt_adjectiveAtEnd=*/true), true);
+        menu.addChild(this.makeMenuItem(
+            'Run', '執行', 'untested', '未測的',
+            (numLeavesByStatus['UNTESTED'] || 0) +
+            (numLeavesByStatus['ACTIVE'] || 0),
+            test, function(event) {
+                this.sendEvent('goofy:auto_run', {'path': path});
+            }), true);
+    }
+    menu.addChild(new goog.ui.MenuSeparator(), true);
+    // TODO(jsalz): This isn't quite right since it stops all tests.
+    // But close enough for now.
+    menu.addChild(this.makeMenuItem('Stop', '停止', 'active', '正在跑的',
+                                    numLeavesByStatus['ACTIVE'] || 0,
+                                    test, function(event) {
+        this.sendEvent('goofy:stop');
+    }), true);
+
+    var item = new goog.ui.MenuItem('Show test logs...');
+    item.setEnabled(test.state.status != 'UNTESTED');
+    goog.events.listen(item, goog.ui.Component.EventType.ACTION,
+                       function(event) {
+                           this.showTestLogs(allPaths);
+                       }, true, this);
+    // Disable 'Show test logs...' for now since it is presented
+    // behind the running test; we'd need to hide test to show it
+    // properly.  TODO(jsalz): Re-enable.
+    // menu.addChild(item, true);
+
+    menu.render(document.body);
+    menu.showAtElement(labelElement,
+                     goog.positioning.Corner.BOTTOM_LEFT,
+                     goog.positioning.Corner.TOP_LEFT);
+    goog.events.listen(menu, goog.ui.Component.EventType.HIDE,
+                       function(event) {
+                           menu.dispose();
+                           this.contextMenuVisible = false;
+                       }, true, this);
+};
+
+/**
+ * Updates the tooltip for a test based on its status.
+ * The tooltip will be displayed only for failed tests.
+ * @param {string} path
+ * @param {goog.ui.AdvancedTooltip} tooltip
+ * @param {goog.events.Event} event the BEFORE_SHOW event that will cause the
+ *     tooltip to be displayed.
+ */
+cros.factory.Goofy.prototype.updateTestToolTip =
+    function(path, tooltip, event) {
+    var test = this.pathTestMap[path];
+
+    tooltip.setHtml('')
+
+    var errorMsg = test.state['error_msg'];
+    if (test.state.status != 'FAILED' || this.contextMenuVisible || !errorMsg) {
+        // Don't bother showing it.
+        event.preventDefault();
+    } else {
+        // Show the last failure.
+        var lines = errorMsg.split('\n');
+        var html = ('Failure in "' + test.label_en + '":' +
+                    '<div class="goofy-test-failure">' +
+                    goog.string.htmlEscape(lines.shift()) + '</span>');
+
+        if (lines.length) {
+            html += ('<div class="goofy-test-failure-detail-link">' +
+                     'Show more detail...</div>' +
+                     '<div class="goofy-test-failure-detail">' +
+                     goog.string.htmlEscape(lines.join('\n')) + '</div>');
+        }
+
+        tooltip.setHtml(html);
+
+        if (lines.length) {
+            var link = goog.dom.getElementByClass(
+            'goofy-test-failure-detail-link', tooltip.getElement());
+            goog.events.listen(
+                link, goog.events.EventType.CLICK,
+                function(event) {
+                    goog.dom.classes.add(tooltip.getElement(),
+                                         'goofy-test-failure-expanded');
+                    tooltip.reposition();
+            }, true, this);
+        }
+    }
+};
+
+/**
+ * Sets up the UI for a the test list.  (Should be invoked only once, when
+ * the test list is received.)
+ * @param {cros.factory.TestListEntry} testList the test list (the return value
+ *     of the get_test_list RPC call).
+ */
+cros.factory.Goofy.prototype.setTestList = function(testList) {
+    cros.factory.logger.info('Received test list: ' +
+        goog.debug.expose(testList));
+    goog.style.showElement(document.getElementById('goofy-loading'), false);
+
+    this.addToNode(null, testList);
+    // expandAll is necessary to get all the elements to actually be
+    // created right away so we can add listeners.  We'll collapse it later.
+    this.testTree.expandAll();
+    this.testTree.render(document.getElementById('goofy-test-tree'));
+
+    var addListener = goog.bind(function(path, labelElement, rowElement) {
+        var tooltip = new goog.ui.AdvancedTooltip(rowElement);
+        tooltip.setHideDelayMs(1000);
+        this.tooltips.push(tooltip);
+        goog.events.listen(
+            tooltip, goog.ui.Component.EventType.BEFORE_SHOW,
+            function(event) {
+                this.updateTestToolTip(path, tooltip, event);
+            }, true, this)
+        goog.events.listen(
+            rowElement, goog.events.EventType.CONTEXTMENU,
+            function(event) {
+                this.showTestPopup(path, labelElement);
+                event.stopPropagation();
+                event.preventDefault();
+            }, true, this);
+        goog.events.listen(
+            labelElement, goog.events.EventType.MOUSEDOWN,
+            function(event) {
+                this.showTestPopup(path, labelElement);
+                event.stopPropagation();
+                event.preventDefault();
+            }, true, this);
+    }, this);
+
+    for (var path in this.pathNodeMap) {
+        var node = this.pathNodeMap[path];
+        addListener(path, node.getLabelElement(), node.getRowElement());
+    }
+
+    goog.array.forEach([goog.events.EventType.MOUSEDOWN,
+                        goog.events.EventType.CONTEXTMENU],
+        function(eventType) {
+            goog.events.listen(
+                document.getElementById('goofy-title'),
+                eventType,
+                function(event) {
+                    var updateItem = new goog.ui.MenuItem(
+                        cros.factory.Content('Update factory software',
+                                             '更新工廠軟體'));
+                    goog.events.listen(
+                        updateItem, goog.ui.Component.EventType.ACTION,
+                        function(event) {
+                            this.sendEvent('goofy:update_factory', {});
+                        }, true, this);
+
+                    this.showTestPopup(
+                        '', document.getElementById('goofy-logo-text'),
+                        [updateItem]);
+
+                    event.stopPropagation();
+                    event.preventDefault();
+                }, true, this);
+        }, this);
+
+    this.testTree.collapseAll();
+    this.sendRpc('get_test_states', [], function(stateMap) {
+        for (var path in stateMap) {
+            if (!goog.string.startsWith(path, "_")) {  // e.g., __jsonclass__
+                this.setTestState(path, stateMap[path]);
+            }
+        }
+    });
+};
+
+/**
+ * Sets the state for a particular test.
+ * @param {string} path
+ * @param {Object.<string, Object>} state the TestState object (contained in
+ *     an event or as a response to the RPC call).
+ */
+cros.factory.Goofy.prototype.setTestState = function(path, state) {
+    var node = this.pathNodeMap[path];
+    if (!node) {
+        cros.factory.logger.warning('No node found for test path ' + path);
+        return;
+    }
+
+    var elt = this.pathNodeMap[path].getElement();
+    var test = this.pathTestMap[path];
+    test.state = state;
+
+    // Assign the appropriate class to the node, and remove all other
+    // status classes.
+    goog.dom.classes.addRemove(
+        elt,
+        goog.array.filter(
+            goog.dom.classes.get(elt),
+            function(cls) {
+                return goog.string.startsWith(cls, "goofy-status-") && cls
+            }),
+        'goofy-status-' + state.status.toLowerCase());
+
+    if (state.status == 'ACTIVE') {
+        // Automatically show the test if it is running.
+        node.reveal();
+    } else if (cros.factory.AUTO_COLLAPSE) {
+        // If collapsible, then collapse it in 250ms if still inactive.
+        if (node.getChildCount() != 0) {
+            window.setTimeout(function(event) {
+                    if (test.state.status != 'ACTIVE') {
+                        node.collapse();
+                    }
+                }, 250);
+        }
+    }
+};
+
+/**
+ * Adds a test node to the tree.
+ * @param {goog.ui.tree.BaseNode} parent
+ * @param {cros.factory.TestListEntry} test
+ */
+cros.factory.Goofy.prototype.addToNode = function(parent, test) {
+    var node;
+    if (parent == null) {
+        node = this.testTree;
+    } else {
+        var label = '<span class="goofy-label-en">' +
+            goog.string.htmlEscape(test.label_en) + '</span>';
+        label += '<span class="goofy-label-zh">' +
+            goog.string.htmlEscape(test.label_zh || test.label_en) + '</span>';
+        if (test.kbd_shortcut) {
+            label = '<span class="goofy-kbd-shortcut">Alt-' +
+                goog.string.htmlEscape(test.kbd_shortcut.toUpperCase()) +
+                '</span>' + label;
+        }
+        node = this.testTree.createNode(label);
+        parent.addChild(node);
+    }
+    goog.array.forEach(test.subtests, function(subtest) {
+            this.addToNode(node, subtest);
+        }, this);
+
+    node.setIconClass('goofy-test-icon');
+    node.setExpandedIconClass('goofy-test-icon');
+
+    this.pathNodeMap[test.path] = node;
+    this.pathTestMap[test.path] = test;
+    node.factoryTest = test;
+};
+
+/**
+ * Sends an event to Goofy.
+ * @param {string} type the event type (e.g., 'goofy:hello').
+ * @param {Object} properties of event.
+ */
+cros.factory.Goofy.prototype.sendEvent = function(type, properties) {
+    var dict = goog.object.clone(properties);
+    dict.type = type;
+    var serialized = goog.json.serialize(dict);
+    cros.factory.logger.info('Sending event: ' + serialized);
+    this.ws.send(serialized);
+};
+
+/**
+ * Calls an RPC function and invokes callback with the result.
+ * @param {Object} args
+ * @param {Object=} callback
+ */
+cros.factory.Goofy.prototype.sendRpc = function(method, args, callback) {
+    var request = goog.json.serialize({method: method, params: args, id: 1});
+    cros.factory.logger.info('RPC request: ' + request);
+    var factoryThis = this;
+    goog.net.XhrIo.send(
+        '/', function() {
+            cros.factory.logger.info('RPC response for ' + method + ': ' +
+                                     this.getResponseText());
+            // TODO(jsalz): handle errors
+            if (callback) {
+                callback.call(
+                    factoryThis,
+                    goog.json.unsafeParse(this.getResponseText()).result);
+            }
+        },
+        'POST', request);
+};
+
+/**
+ * Sends a keepalive event if the web socket is open.
+ */
+cros.factory.Goofy.prototype.keepAlive = function() {
+    if (this.ws.isOpen()) {
+        this.sendEvent('goofy:keepalive', {'uuid': this.uuid});
+    }
+};
+
+cros.factory.Goofy.prototype.LOAD_AVERAGE_FORMAT = (
+    new goog.i18n.NumberFormat('0.00'));
+cros.factory.Goofy.prototype.PERCENT_CPU_FORMAT = (
+    new goog.i18n.NumberFormat('0.0%'));
+cros.factory.Goofy.prototype.PERCENT_BATTERY_FORMAT = (
+    new goog.i18n.NumberFormat('0%'));
+/**
+ * Gets the system status.
+ */
+cros.factory.Goofy.prototype.updateStatus = function() {
+    this.sendRpc('get_system_status', [], function(status) {
+        function setValue(id, value) {
+            var element = document.getElementById(id);
+            goog.dom.classes.enable(element, 'goofy-value-known',
+                                    value != null);
+            goog.dom.getElementByClass('goofy-value', element
+                                       ).innerHTML = value;
+        }
+
+        setValue('goofy-load-average',
+                 status['load_avg'] ?
+                 this.LOAD_AVERAGE_FORMAT.format(status['load_avg'][0]) :
+                 null);
+
+        if (this.lastStatus) {
+            var lastCpu = goog.math.sum.apply(this, this.lastStatus['cpu']);
+            var currentCpu = goog.math.sum.apply(this, status['cpu']);
+            var lastIdle = this.lastStatus['cpu'][3];
+            var currentIdle = status['cpu'][3];
+            var deltaIdle = currentIdle - lastIdle;
+            var deltaTotal = currentCpu - lastCpu;
+            setValue('goofy-percent-cpu',
+                     this.PERCENT_CPU_FORMAT.format(
+                         (deltaTotal - deltaIdle) / deltaTotal));
+        } else {
+            setValue('goofy-percent-cpu', null);
+        }
+
+        var chargeIndicator = document.getElementById(
+            'goofy-battery-charge-indicator');
+        var percent = null;
+        var batteryStatus = 'unknown';
+        if (status.battery && status.battery.charge_full) {
+            percent = this.PERCENT_BATTERY_FORMAT.format(
+                status.battery.charge_now / status.battery.charge_full);
+            if (goog.array.contains(['Full', 'Charging', 'Discharging'],
+                                    status.battery.status)) {
+                batteryStatus = status.battery.status.toLowerCase();
+            }
+        }
+        setValue('goofy-percent-battery', percent);
+        goog.dom.classes.set(
+            chargeIndicator, 'goofy-battery-' + batteryStatus);
+
+        this.lastStatus = status;
+    });
+};
+
+/**
+ * Writes a message to the console log.
+ * @param {string} message
+ * @param {Object|Array.<string>|string=} opt_attributes attributes to add
+ *     to the div element containing the log entry.
+ */
+cros.factory.Goofy.prototype.logToConsole = function(message, opt_attributes) {
+    var div = goog.dom.createDom('div', opt_attributes);
+    goog.dom.classes.add(div, 'goofy-log-line');
+    div.appendChild(document.createTextNode(message));
+    this.console.appendChild(div);
+    // Scroll to bottom.  TODO(jsalz): Scroll only if already at the bottom,
+    // or add scroll lock.
+    var scrollPane = goog.dom.getAncestorByClass(this.console,
+        'goog-splitpane-second-container');
+    scrollPane.scrollTop = scrollPane.scrollHeight;
+};
+
+/**
+ * Logs an "internal" message to the console (as opposed to a line from
+ * console.log).
+ */
+cros.factory.Goofy.prototype.logInternal = function(message) {
+    this.logToConsole(message, 'goofy-internal-log');
+};
+
+/**
+ * Handles an event sends from the backend.
+ * @param {string} jsonMessage the message as a JSON string.
+ */
+cros.factory.Goofy.prototype.handleBackendEvent = function(jsonMessage) {
+    cros.factory.logger.info('Got message: ' + jsonMessage);
+    var message = /** @type Object.<string, Object> */ (
+        goog.json.unsafeParse(jsonMessage));
+
+    if (message.type == 'goofy:hello') {
+        if (this.uuid && message.uuid != this.uuid) {
+            // The goofy process has changed; reload the page.
+            cros.factory.logger.info('Incorrect UUID; reloading');
+            window.location.reload();
+            return;
+        } else {
+            this.uuid = message.uuid;
+            // Send a keepAlive to confirm the UUID with the backend.
+            this.keepAlive();
+            // TODO(jsalz): Process version number information.
+        }
+    } else if (message.type == 'goofy:log') {
+        this.logToConsole(message.message);
+    } else if (message.type == 'goofy:state_change') {
+        this.setTestState(message.path, message.state);
+    } else if (message.type == 'goofy:set_html') {
+        var invocation = this.getOrCreateInvocation(
+            message.test, message.invocation);
+        if (invocation) {
+            if (!message.append && invocation.iframe.contentDocument.body) {
+                goog.dom.removeChildren(invocation.iframe.contentDocument.body);
+            }
+            invocation.iframe.contentDocument.write(message['html']);
+        }
+    } else if (message.type == 'goofy:run_js') {
+        var invocation = this.getOrCreateInvocation(
+            message.test, message.invocation);
+        if (invocation) {
+            // We need to evaluate the code in the context of the content
+            // window, but we also need to give it a variable.  Stash it
+            // in the window and load it directly in the eval command.
+            invocation.iframe.contentWindow.__goofy_args = message['args'];
+            invocation.iframe.contentWindow.eval(
+                'var args = window.__goofy_args;' +
+                /** @type string */ (message['js']));
+            delete invocation.iframe.contentWindow.__goofy_args;
+        }
+    } else if (message.type == 'goofy:call_js_function') {
+        var invocation = this.getOrCreateInvocation(
+            message.test, message.invocation);
+        if (invocation) {
+            var func = invocation.iframe.contentWindow.eval(message['name']);
+            if (func) {
+                func.apply(invocation.iframe.contentWindow, message['args']);
+            } else {
+                cros.factory.logger.severe('Unable to find function ' + func +
+                                           ' in UI for test ' + message.test);
+            }
+        }
+    } else if (message.type == 'goofy:destroy_test') {
+        var invocation = this.invocations[message.invocation];
+        if (invocation) {
+            invocation.dispose();
+        }
+    } else if (message.type == 'goofy:system_info') {
+        this.setSystemInfo(message['system_info']);
+    } else if (message.type == 'goofy:pending_shutdown') {
+        this.setPendingShutdown(
+            /** @type {cros.factory.PendingShutdownEvent} */(message));
+    }
+};
+
+goog.events.listenOnce(window, goog.events.EventType.LOAD, function() {
+        window.goofy = new cros.factory.Goofy();
+        window.goofy.init();
+    });
diff --git a/py/goofy/static/.gitignore b/py/goofy/static/.gitignore
new file mode 100644
index 0000000..3a82a2a
--- /dev/null
+++ b/py/goofy/static/.gitignore
@@ -0,0 +1,2 @@
+goofy.js
+closure.css
diff --git a/py/goofy/static/Makefile b/py/goofy/static/Makefile
new file mode 100644
index 0000000..838a97d
--- /dev/null
+++ b/py/goofy/static/Makefile
@@ -0,0 +1,60 @@
+# Copyright (c) 2012 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.
+
+BUILD_DEPS=../../../build_deps
+
+CLOSURE_LIB_VERSION=20111110-r1376
+
+CLOSURE_LIB_ARCHIVE := $(BUILD_DEPS)/closure_library/closure-library-
+CLOSURE_LIB_ARCHIVE := $(CLOSURE_LIB_ARCHIVE)$(CLOSURE_LIB_VERSION).tar.bz2
+CLOSURE_LIB_DIR := $(BUILD_DEPS)/closure_library/closure-library-
+CLOSURE_LIB_DIR := $(CLOSURE_LIB_DIR)$(CLOSURE_LIB_VERSION)
+
+CLOSURE_COMPILER_JAR=/opt/closure-compiler-bin-0/lib/closure-compiler-bin.jar
+
+CSS_SOURCES=\
+    goog/css/checkbox.css \
+    goog/css/dialog.css \
+    goog/css/menu.css \
+    goog/css/menuitem.css \
+    goog/css/menuseparator.css \
+    goog/css/submenu.css \
+    goog/css/tooltip.css \
+    goog/css/tree.css
+
+CSS_SOURCE_PATHS=$(addprefix $(CLOSURE_LIB_DIR)/closure/,$(CSS_SOURCES))
+
+CLOSURE_BUILD=\
+    $(CLOSURE_LIB_DIR)/closure/bin/build/closurebuilder.py \
+    --root $(CLOSURE_LIB_DIR) \
+    --root ../js \
+    -n cros.factory.Goofy \
+    --compiler_jar=$(CLOSURE_COMPILER_JAR) \
+    -f \
+    --warning_level=VERBOSE
+
+.PHONY: all
+all: goofy.js closure.css
+
+# Dummy file to represent extraction of the tarball.
+EXTRACTED=$(CLOSURE_LIB_DIR)/.extracted
+$(EXTRACTED): $(CLOSURE_LIB_ARCHIVE)
+	tar xf $< -C $(dir $(CLOSURE_LIB_DIR))
+	touch $@
+
+# For now, we just use the compiler (--output_mode=compiled) to check
+# the correctness of our code, and we actually deploy the version that is
+# just the concatenation of all the dependencies (--output_mode=script).
+goofy.js: $(EXTRACTED) ../js/goofy.js
+	$(CLOSURE_BUILD) --output_mode=compiled --output_file=/dev/null
+	$(CLOSURE_BUILD) --output_mode=script --output_file=$@
+
+$(CSS_SOURCE_PATHS): $(EXTRACTED)
+
+closure.css: $(CSS_SOURCE_PATHS)
+	cat $^ > $@
+
+.PHONY: clean
+clean:
+	rm -rf $(CLOSURE_LIB_DIR) goofy.js closure.css
diff --git a/py/goofy/static/fonts/LICENSE.txt b/py/goofy/static/fonts/LICENSE.txt
new file mode 100644
index 0000000..75b5248
--- /dev/null
+++ b/py/goofy/static/fonts/LICENSE.txt
@@ -0,0 +1,202 @@
+

+                                 Apache License

+                           Version 2.0, January 2004

+                        http://www.apache.org/licenses/

+

+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

+

+   1. Definitions.

+

+      "License" shall mean the terms and conditions for use, reproduction,

+      and distribution as defined by Sections 1 through 9 of this document.

+

+      "Licensor" shall mean the copyright owner or entity authorized by

+      the copyright owner that is granting the License.

+

+      "Legal Entity" shall mean the union of the acting entity and all

+      other entities that control, are controlled by, or are under common

+      control with that entity. For the purposes of this definition,

+      "control" means (i) the power, direct or indirect, to cause the

+      direction or management of such entity, whether by contract or

+      otherwise, or (ii) ownership of fifty percent (50%) or more of the

+      outstanding shares, or (iii) beneficial ownership of such entity.

+

+      "You" (or "Your") shall mean an individual or Legal Entity

+      exercising permissions granted by this License.

+

+      "Source" form shall mean the preferred form for making modifications,

+      including but not limited to software source code, documentation

+      source, and configuration files.

+

+      "Object" form shall mean any form resulting from mechanical

+      transformation or translation of a Source form, including but

+      not limited to compiled object code, generated documentation,

+      and conversions to other media types.

+

+      "Work" shall mean the work of authorship, whether in Source or

+      Object form, made available under the License, as indicated by a

+      copyright notice that is included in or attached to the work

+      (an example is provided in the Appendix below).

+

+      "Derivative Works" shall mean any work, whether in Source or Object

+      form, that is based on (or derived from) the Work and for which the

+      editorial revisions, annotations, elaborations, or other modifications

+      represent, as a whole, an original work of authorship. For the purposes

+      of this License, Derivative Works shall not include works that remain

+      separable from, or merely link (or bind by name) to the interfaces of,

+      the Work and Derivative Works thereof.

+

+      "Contribution" shall mean any work of authorship, including

+      the original version of the Work and any modifications or additions

+      to that Work or Derivative Works thereof, that is intentionally

+      submitted to Licensor for inclusion in the Work by the copyright owner

+      or by an individual or Legal Entity authorized to submit on behalf of

+      the copyright owner. For the purposes of this definition, "submitted"

+      means any form of electronic, verbal, or written communication sent

+      to the Licensor or its representatives, including but not limited to

+      communication on electronic mailing lists, source code control systems,

+      and issue tracking systems that are managed by, or on behalf of, the

+      Licensor for the purpose of discussing and improving the Work, but

+      excluding communication that is conspicuously marked or otherwise

+      designated in writing by the copyright owner as "Not a Contribution."

+

+      "Contributor" shall mean Licensor and any individual or Legal Entity

+      on behalf of whom a Contribution has been received by Licensor and

+      subsequently incorporated within the Work.

+

+   2. Grant of Copyright License. Subject to the terms and conditions of

+      this License, each Contributor hereby grants to You a perpetual,

+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable

+      copyright license to reproduce, prepare Derivative Works of,

+      publicly display, publicly perform, sublicense, and distribute the

+      Work and such Derivative Works in Source or Object form.

+

+   3. Grant of Patent License. Subject to the terms and conditions of

+      this License, each Contributor hereby grants to You a perpetual,

+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable

+      (except as stated in this section) patent license to make, have made,

+      use, offer to sell, sell, import, and otherwise transfer the Work,

+      where such license applies only to those patent claims licensable

+      by such Contributor that are necessarily infringed by their

+      Contribution(s) alone or by combination of their Contribution(s)

+      with the Work to which such Contribution(s) was submitted. If You

+      institute patent litigation against any entity (including a

+      cross-claim or counterclaim in a lawsuit) alleging that the Work

+      or a Contribution incorporated within the Work constitutes direct

+      or contributory patent infringement, then any patent licenses

+      granted to You under this License for that Work shall terminate

+      as of the date such litigation is filed.

+

+   4. Redistribution. You may reproduce and distribute copies of the

+      Work or Derivative Works thereof in any medium, with or without

+      modifications, and in Source or Object form, provided that You

+      meet the following conditions:

+

+      (a) You must give any other recipients of the Work or

+          Derivative Works a copy of this License; and

+

+      (b) You must cause any modified files to carry prominent notices

+          stating that You changed the files; and

+

+      (c) You must retain, in the Source form of any Derivative Works

+          that You distribute, all copyright, patent, trademark, and

+          attribution notices from the Source form of the Work,

+          excluding those notices that do not pertain to any part of

+          the Derivative Works; and

+

+      (d) If the Work includes a "NOTICE" text file as part of its

+          distribution, then any Derivative Works that You distribute must

+          include a readable copy of the attribution notices contained

+          within such NOTICE file, excluding those notices that do not

+          pertain to any part of the Derivative Works, in at least one

+          of the following places: within a NOTICE text file distributed

+          as part of the Derivative Works; within the Source form or

+          documentation, if provided along with the Derivative Works; or,

+          within a display generated by the Derivative Works, if and

+          wherever such third-party notices normally appear. The contents

+          of the NOTICE file are for informational purposes only and

+          do not modify the License. You may add Your own attribution

+          notices within Derivative Works that You distribute, alongside

+          or as an addendum to the NOTICE text from the Work, provided

+          that such additional attribution notices cannot be construed

+          as modifying the License.

+

+      You may add Your own copyright statement to Your modifications and

+      may provide additional or different license terms and conditions

+      for use, reproduction, or distribution of Your modifications, or

+      for any such Derivative Works as a whole, provided Your use,

+      reproduction, and distribution of the Work otherwise complies with

+      the conditions stated in this License.

+

+   5. Submission of Contributions. Unless You explicitly state otherwise,

+      any Contribution intentionally submitted for inclusion in the Work

+      by You to the Licensor shall be under the terms and conditions of

+      this License, without any additional terms or conditions.

+      Notwithstanding the above, nothing herein shall supersede or modify

+      the terms of any separate license agreement you may have executed

+      with Licensor regarding such Contributions.

+

+   6. Trademarks. This License does not grant permission to use the trade

+      names, trademarks, service marks, or product names of the Licensor,

+      except as required for reasonable and customary use in describing the

+      origin of the Work and reproducing the content of the NOTICE file.

+

+   7. Disclaimer of Warranty. Unless required by applicable law or

+      agreed to in writing, Licensor provides the Work (and each

+      Contributor provides its Contributions) on an "AS IS" BASIS,

+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or

+      implied, including, without limitation, any warranties or conditions

+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A

+      PARTICULAR PURPOSE. You are solely responsible for determining the

+      appropriateness of using or redistributing the Work and assume any

+      risks associated with Your exercise of permissions under this License.

+

+   8. Limitation of Liability. In no event and under no legal theory,

+      whether in tort (including negligence), contract, or otherwise,

+      unless required by applicable law (such as deliberate and grossly

+      negligent acts) or agreed to in writing, shall any Contributor be

+      liable to You for damages, including any direct, indirect, special,

+      incidental, or consequential damages of any character arising as a

+      result of this License or out of the use or inability to use the

+      Work (including but not limited to damages for loss of goodwill,

+      work stoppage, computer failure or malfunction, or any and all

+      other commercial damages or losses), even if such Contributor

+      has been advised of the possibility of such damages.

+

+   9. Accepting Warranty or Additional Liability. While redistributing

+      the Work or Derivative Works thereof, You may choose to offer,

+      and charge a fee for, acceptance of support, warranty, indemnity,

+      or other liability obligations and/or rights consistent with this

+      License. However, in accepting such obligations, You may act only

+      on Your own behalf and on Your sole responsibility, not on behalf

+      of any other Contributor, and only if You agree to indemnify,

+      defend, and hold each Contributor harmless for any liability

+      incurred by, or claims asserted against, such Contributor by reason

+      of your accepting any such warranty or additional liability.

+

+   END OF TERMS AND CONDITIONS

+

+   APPENDIX: How to apply the Apache License to your work.

+

+      To apply the Apache License to your work, attach the following

+      boilerplate notice, with the fields enclosed by brackets "[]"

+      replaced with your own identifying information. (Don't include

+      the brackets!)  The text should be enclosed in the appropriate

+      comment syntax for the file format. We also recommend that a

+      file or class name and description of purpose be included on the

+      same "printed page" as the copyright notice for easier

+      identification within third-party archives.

+

+   Copyright [yyyy] [name of copyright owner]

+

+   Licensed under the Apache License, Version 2.0 (the "License");

+   you may not use this file except in compliance with the License.

+   You may obtain a copy of the License at

+

+       http://www.apache.org/licenses/LICENSE-2.0

+

+   Unless required by applicable law or agreed to in writing, software

+   distributed under the License is distributed on an "AS IS" BASIS,

+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

+   See the License for the specific language governing permissions and

+   limitations under the License.

diff --git a/py/goofy/static/fonts/OpenSans-Bold-Latin.ttf b/py/goofy/static/fonts/OpenSans-Bold-Latin.ttf
new file mode 100644
index 0000000..3c799a4
--- /dev/null
+++ b/py/goofy/static/fonts/OpenSans-Bold-Latin.ttf
Binary files differ
diff --git a/py/goofy/static/fonts/OpenSans-BoldItalic-Latin.ttf b/py/goofy/static/fonts/OpenSans-BoldItalic-Latin.ttf
new file mode 100644
index 0000000..1983a0a
--- /dev/null
+++ b/py/goofy/static/fonts/OpenSans-BoldItalic-Latin.ttf
Binary files differ
diff --git a/py/goofy/static/fonts/OpenSans-Italic-Latin.ttf b/py/goofy/static/fonts/OpenSans-Italic-Latin.ttf
new file mode 100644
index 0000000..3b903d5
--- /dev/null
+++ b/py/goofy/static/fonts/OpenSans-Italic-Latin.ttf
Binary files differ
diff --git a/py/goofy/static/fonts/OpenSans-Latin.ttf b/py/goofy/static/fonts/OpenSans-Latin.ttf
new file mode 100644
index 0000000..1d6f3fa
--- /dev/null
+++ b/py/goofy/static/fonts/OpenSans-Latin.ttf
Binary files differ
diff --git a/py/goofy/static/fonts/README.txt b/py/goofy/static/fonts/README.txt
new file mode 100644
index 0000000..0180147
--- /dev/null
+++ b/py/goofy/static/fonts/README.txt
@@ -0,0 +1,2 @@
+Files in this directory were obtained from
+<http://code.google.com/p/googlefontdirectory/source/browse/opensans>.
diff --git a/py/goofy/static/goofy.css b/py/goofy/static/goofy.css
new file mode 100644
index 0000000..818e589
--- /dev/null
+++ b/py/goofy/static/goofy.css
@@ -0,0 +1,403 @@
+/*
+ * Copyright (c) 2012 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.
+ */
+
+/* CSS reset. */
+
+html, body, div, span, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+abbr, address, cite, code,
+del, dfn, em, img, ins, kbd, q, samp,
+small, strong, sub, sup, var,
+b, i,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, figcaption, figure, 
+footer, header, hgroup, menu, nav, section, summary,
+time, mark, audio, video {
+  margin: 0;
+  padding: 0;
+  border: 0;
+  outline: 0;
+  font-size: 100%;
+  font-family: 'Open Sans', sans-serif;
+  vertical-align: baseline;
+  background: transparent;
+}
+
+/* Language-specific labels.  Default to display: none, but set display: inline
+   when the respective language class is present. */
+.goofy-label-en, .goofy-label-zh {
+  display: none;
+}
+.goofy-lang-en .goofy-label-en, .goofy-lang-zh .goofy-label-zh {
+  display: inline;
+}
+
+/* Top-level UI elements. */
+body {
+  overflow: hidden;
+}
+.goofy-horizontal-border {
+  position: relative;
+  overflow: hidden;
+  top: 0; right: 0; height: 4px; width: 100%;
+}
+.goofy-horizontal-border-1 {
+  position: absolute;
+  left: 0; width: 100%; height: 100%;
+  background-color: #4172a0;
+}
+.goofy-horizontal-border-2 {
+  position: absolute;
+  left: 25%; width: 100%; height: 100%;
+  background-color: #779fd3;
+}
+.goofy-horizontal-border-3 {
+  position: absolute;
+  left: 50%; width: 100%; height: 100%;
+  background-color: #85b4df;
+}
+.goofy-horizontal-border-4 {
+  position: absolute;
+  left: 75%; width: 100%; height: 100%;
+  background-color: #cddff0;
+}
+#goofy-control {
+  position: absolute;
+  top: 0; left: 0; right: 0; bottom: 20px;
+}
+#goofy-control, #goofy-main-and-console .goog-splitpane-second-container {
+  overflow: auto;
+}
+#goofy-console {
+  padding: .5em;
+}
+#goofy-main {
+  left: 0; top: 0;
+  width: 100%; height: 100%;
+}
+#goofy-main iframe {
+  position: absolute;
+  left: 0; top: 0;
+  width: 100%; height: 100%;
+  padding: .5em;
+}
+
+/* Overrides for status bar. */
+#goofy-status-bar {
+  border-top: 1px solid lightgray;
+  font-size: 75%;
+  height: 20px; left: 0; right: 0; bottom: 0;
+  position: absolute;
+}
+#goofy-status-bar-main {
+  padding-top: 4px;
+}
+.goofy-status-bar-item {
+  padding-left: .5em;
+}
+.goofy-status-bar-item:not(.goofy-value-known) .goofy-value {
+  display: none;
+}
+.goofy-status-bar-item.goofy-value-known .goofy-unknown {
+  display: none;
+}
+.goofy-battery-charging:before {
+  content: '↑';
+}
+.goofy-battery-discharging:before {
+  content: '↓';
+}
+.goofy-battery-full:before {
+  content: 'F';
+}
+.goofy-engineering-mode #goofy-status-bar {
+  /* Status bar contains the engineering-mode indicator. */
+  height: 44px;
+  /* No border necessary. */
+  border-top: 0px;
+}
+#goofy-engineering-mode-indicator {
+  background-color: red;
+  color: white;
+  display: none;
+  font-size: 150%;
+  font-weight: bold;
+  height: 24px;
+  line-height: 24px;
+  text-align: center;
+  left: 0; right: 0; bottom: 0; 
+}
+#goofy-engineering-mode-indicator a {
+  color: white;
+}
+.goofy-engineering-mode #goofy-engineering-mode-indicator {
+  display: block;
+}
+.goofy-engineering-mode #goofy-control {
+  bottom: 44px;
+}
+#goofy-disable-engineering-mode {
+  border: none;
+  cursor: pointer;
+  margin-left: 1em;
+  vertical-align: -5px;
+  width: 21; height: 21;
+}
+
+/* Elements in the control pane. */
+#goofy-control h1 {
+  font-size: 125%;
+  padding: .25em;
+}
+#goofy-logo {
+  height: 22px;
+  vertical-align: middle;
+}
+#goofy-logo-text {
+  vertical-align: middle;
+  cursor: pointer;
+}
+#goofy-logo-down-arrow {
+  font-size: 60%;
+  vertical-align: .25em;
+}
+#goofy-language-selector {
+  float: right;
+  padding: .675em .5em .5em .5em;
+}
+#goofy-language:hover {
+  text-decoration: underline;
+  cursor: pointer;
+}
+#goofy-loading {
+  font-style: italic;
+  font-size: 75%;
+}
+#goofy-loading img {
+  margin-right: 3px;
+  vertical-align: bottom;
+}
+.goofy-kbd-shortcut {
+  float: right;
+  font-size: 75%;
+  padding-right: .5em;
+  color: darkgray;
+}
+
+.goofy-test-icon {
+  background-repeat: no-repeat;
+  background-position: center;
+  vertical-align: -2px;
+  width: 16px; height: 16px;
+}
+.goofy-status-passed > div > .goofy-test-icon {
+  background-image: url(images/passed.gif);
+}
+.goofy-status-active > div > .goofy-test-icon {
+  background-image: url(images/active.gif);
+}
+.goofy-status-failed > div > .goofy-test-icon {
+  background-image: url(images/failed.gif);
+}
+.goofy-status-untested > div > .goofy-test-icon {
+  background-image: url(images/untested.gif);
+}
+.goofy-test-failure-expanded .goofy-test-failure-detail-link {
+  display: none;
+}
+.goofy-test-failure-expanded .goofy-test-failure-detail {
+  display: block;
+}
+.goofy-test-failure-detail-link {
+  text-decoration: underline;
+  cursor: pointer;
+  color: blue;
+  font-style: italic;
+  font-size: 75%;
+  padding-top: 1em;
+}
+.goofy-test-failure-detail {
+  white-space: pre;
+  display: none;
+  font-size: 75%;
+  padding-top: 1em;
+}
+.goofy-history dt {
+  font-weight: bold;
+}
+.goofy-history dd {
+  font-size: 75%;
+  white-space: pre;
+}
+dl.goofy-history {
+  overflow-x: hidden;
+  overflow-y: scroll;
+  padding-right: 1em;
+  padding-bottom: 1em;
+}
+.goofy-history dd {
+  padding-right: 1em;
+  padding-bottom: 1em;
+}
+.goofy-history dd + dt {
+  border-top: 1px solid lightgray;
+  padding-right: 1em;
+  padding-top: 1em;
+}
+#goofy-system-info th, #goofy-system-info td {
+  font-size: 75%;
+  text-align: left;
+  padding: 0 .1em 0 .1em;
+  white-space: nowrap;
+}
+#goofy-system-info th {
+  padding-right: 1em;
+}
+#goofy-system-info-hover {
+  float: right;
+  margin-top: -1px;
+}
+.goofy-unknown {
+  color: darkgray;
+  font-style: italic;
+}
+
+/* Elements in the console pane. */
+.goofy-internal-log {
+  font-style: italic;
+}
+
+/* Dialogs. */
+.goofy-shutdown-dialog .modal-dialog-content {
+  font-size: 200%;
+  padding: 0.5em;
+}
+.goofy-shutdown-dialog .progress-bar-horizontal {
+  margin: .25em 0;
+}
+.goofy-shutdown-dialog p {
+  padding-bottom: 0.5em;
+}
+.goofy-engineering-mode-dialog .modal-dialog-userInput {
+  font-size: 200%;
+  -webkit-text-security: disc;
+}
+.goofy-engineering-mode-dialog br {
+  display: none;
+}
+
+/* Overrides for Closure components. */
+#goofy .goog-menu {
+  margin-left: -1em;
+}
+#goofy .goog-menuitem {
+  padding-left: 1em;
+  padding-right: 2em;
+}
+#goofy-control .goog-tree-root {
+  padding-left: .5em;
+}
+#goofy-control .goog-tree-children {
+  background-image: url(images/tree/I.png) !important;
+}
+#goofy-control .goog-tree-icon {
+  background-image: url(images/tree/tree.png);
+}
+#goofy-control .goog-tree-row {
+  height: 20px;
+  line-height: 20px;
+}
+#goofy-control .goog-tree-item-label {
+  margin-left: 2px;
+}
+#goofy-control .goog-tree-expand-icon {
+  vertical-align: -1px;
+}
+/* Use same color, whether or not it's focused. */
+#goofy-control .selected .goog-tree-item-label {
+  background-color: #cddff0;
+}
+.modal-dialog-bg {
+  z-index: 3;
+}
+.modal-dialog {
+  z-index: 4;
+}
+
+/* Definitions for closure components not included in Closure. */
+.goog-splitpane {
+  position: absolute;
+  overflow: hidden;
+  left: 0; right: 0; top: 0; bottom: 0;
+}
+.goog-splitpane-handle {
+  background: #cddff0;
+  position: absolute;
+}
+.goog-splitpane-handle-horizontal {
+  cursor: col-resize;
+}
+.goog-splitpane-handle-vertical {
+  cursor: row-resize;
+}
+.goog-splitpane-first-container,
+.goog-splitpane-second-container {
+  overflow: hidden;
+  position: absolute;
+}
+.progress-bar-vertical,
+.progress-bar-horizontal {
+  position: relative;
+  border: 1px solid #949dad;
+  background: white;
+  overflow: hidden;
+}
+
+.progress-bar-horizontal {
+  width: 100%;
+  height: 24px;
+}
+
+.progress-bar-vertical {
+  width: 14px;
+  height: 200px;
+}
+
+.progress-bar-thumb {
+  position: relative;
+  background: #d4e4ff;
+  overflow: hidden;
+  width: 100%;
+  height: 100%;
+}
+
+/* Fonts. */
+@font-face {
+  font-family: 'Open Sans';
+  font-style: normal;
+  font-weight: 400;
+  src: url('fonts/OpenSans-Latin.ttf') format('truetype');
+}
+@font-face {
+  font-family: 'Open Sans';
+  font-style: italic;
+  font-weight: 400;
+  src: url('fonts/OpenSans-Italic-Latin.ttf') format('truetype');
+}
+@font-face {
+  font-family: 'Open Sans';
+  font-style: normal;
+  font-weight: 700;
+  src: url('fonts/OpenSans-Bold-Latin.ttf') format('truetype');
+}
+@font-face {
+  font-family: 'Open Sans';
+  font-style: italic;
+  font-weight: 700;
+  src: url('fonts/OpenSans-BoldItalic-Latin.ttf') format('truetype');
+}
diff --git a/py/goofy/static/images/active.gif b/py/goofy/static/images/active.gif
new file mode 100644
index 0000000..57fa1b1
--- /dev/null
+++ b/py/goofy/static/images/active.gif
Binary files differ
diff --git a/py/goofy/static/images/cleardot.gif b/py/goofy/static/images/cleardot.gif
new file mode 100644
index 0000000..35d42e8
--- /dev/null
+++ b/py/goofy/static/images/cleardot.gif
Binary files differ
diff --git a/py/goofy/static/images/failed.gif b/py/goofy/static/images/failed.gif
new file mode 100644
index 0000000..22e187a
--- /dev/null
+++ b/py/goofy/static/images/failed.gif
Binary files differ
diff --git a/py/goofy/static/images/info.png b/py/goofy/static/images/info.png
new file mode 100644
index 0000000..fa8e169
--- /dev/null
+++ b/py/goofy/static/images/info.png
Binary files differ
diff --git a/py/goofy/static/images/logo128.png b/py/goofy/static/images/logo128.png
new file mode 100644
index 0000000..b70cd25
--- /dev/null
+++ b/py/goofy/static/images/logo128.png
Binary files differ
diff --git a/py/goofy/static/images/logo32.png b/py/goofy/static/images/logo32.png
new file mode 100644
index 0000000..4d64908
--- /dev/null
+++ b/py/goofy/static/images/logo32.png
Binary files differ
diff --git a/py/goofy/static/images/passed.gif b/py/goofy/static/images/passed.gif
new file mode 100644
index 0000000..4e469c9
--- /dev/null
+++ b/py/goofy/static/images/passed.gif
Binary files differ
diff --git a/py/goofy/static/images/tree/I.png b/py/goofy/static/images/tree/I.png
new file mode 100644
index 0000000..62a05d5
--- /dev/null
+++ b/py/goofy/static/images/tree/I.png
Binary files differ
diff --git a/py/goofy/static/images/tree/tree.png b/py/goofy/static/images/tree/tree.png
new file mode 100644
index 0000000..0dd0ef1
--- /dev/null
+++ b/py/goofy/static/images/tree/tree.png
Binary files differ
diff --git a/py/goofy/static/images/untested.gif b/py/goofy/static/images/untested.gif
new file mode 100644
index 0000000..aed7593
--- /dev/null
+++ b/py/goofy/static/images/untested.gif
Binary files differ
diff --git a/py/goofy/static/index.html b/py/goofy/static/index.html
new file mode 100644
index 0000000..a9d4d12
--- /dev/null
+++ b/py/goofy/static/index.html
@@ -0,0 +1,67 @@
+<html id="goofy">
+  <head>
+    <title>CrOS Factory</title>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+    <link rel="stylesheet" type="text/css" href="closure.css">
+    <link rel="stylesheet" type="text/css" href="goofy.css">
+    <link rel="icon" type="image/png" href="images/logo32.png">
+
+    <script type="text/javascript" src="goofy.js">
+    </script>
+  </head>
+  <body>
+    <div class="goog-splitpane" id="goofy-splitpane">
+      <div class="goog-splitpane-first-container">
+       <div id="goofy-control">
+         <div class="goofy-horizontal-border">
+           <div class="goofy-horizontal-border-1"></div>
+           <div class="goofy-horizontal-border-2"></div>
+           <div class="goofy-horizontal-border-3"></div>
+           <div class="goofy-horizontal-border-4"></div>
+         </div>
+         <span id="goofy-language-selector">
+           <span class="goofy-label-en">En 英</span>
+           <span class="goofy-label-zh">Zh 中</span>
+         </span>
+         <h1 id="goofy-title">
+           <img id="goofy-logo" src="images/logo128.png">
+           <span id="goofy-logo-text">CrOS Factory<span id="goofy-logo-down-arrow"> ▼</span>
+           </span>
+         </h1>
+         <div id="goofy-loading"><img src="images/active.gif">Loading test list...</img></div>
+         <div id="goofy-test-tree"></div>
+       </div>
+       <div id="goofy-status-bar">
+         <div id="goofy-engineering-mode-indicator">
+           <span class="goofy-label-en">Engineering mode</span>
+           <span class="goofy-label-zh">工程模式</span>
+           <img id="goofy-disable-engineering-mode" style="display: none"
+                onclick="goofy.setEngineeringMode(false)"
+                src="white_close.png">
+         </div>
+         <div id="goofy-status-bar-main">
+           <span class="goofy-status-bar-item" id="goofy-load-average">
+             Load:
+             <span class="goofy-value"></span>
+             <span class="goofy-unknown">N/A</span>
+           </span>
+           <span class="goofy-status-bar-item" id="goofy-percent-cpu">
+             CPU:
+             <span class="goofy-value"></span>
+             <span class="goofy-unknown">N/A</span>
+           </span>
+           <span class="goofy-status-bar-item" id="goofy-percent-battery">
+             Battery:
+             <span class="goofy-value"></span
+              ><span class="goofy-unknown">N/A</span
+              ><span id="goofy-battery-charge-indicator"></span>
+           </span>
+           <img id="goofy-system-info-hover" src="images/info.png">
+         </div>
+       </div>
+      </div>
+      <div class="goog-splitpane-handle"></div>
+      <div id="goofy-main-and-console" class="goog-splitpane-second-container"></div>
+    </div>
+  </body>
+</html>
diff --git a/py/goofy/system.py b/py/goofy/system.py
new file mode 100644
index 0000000..0d98db6
--- /dev/null
+++ b/py/goofy/system.py
@@ -0,0 +1,116 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2012 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 re
+import subprocess
+import time
+import yaml
+
+import factory_common
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import shopfloor
+
+
+class SystemInfo(object):
+    '''Static information about the system.
+
+    This is mostly static information that changes rarely if ever
+    (e.g., version numbers, serial numbers, etc.).
+    '''
+    def __init__(self):
+        self.serial_number = None
+        try:
+            self.serial_number = shopfloor.get_serial_number()
+        except:
+            pass
+
+        self.factory_image_version = None
+        try:
+            lsb_release = open('/etc/lsb-release').read()
+            match = re.search('^GOOGLE_RELEASE=(.+)$', lsb_release,
+                              re.MULTILINE)
+            if match:
+                self.factory_image_version = match.group(1)
+        except:
+            pass
+
+        try:
+            self.wlan0_mac = open('/sys/class/net/wlan0/address').read().strip()
+        except:
+            self.wlan0_mac = None
+
+        try:
+            uname = subprocess.Popen(['uname', '-r'], stdout=subprocess.PIPE)
+            stdout, _ = uname.communicate()
+            self.kernel_version = stdout.strip()
+        except:
+            self.kernel_version = None
+
+        self.ec_version = None
+        try:
+            ectool = subprocess.Popen(['mosys', 'ec', 'info', '-l'],
+                                      stdout=subprocess.PIPE)
+            stdout, _ = ectool.communicate()
+            match = re.search('^fw_version\s+\|\s+(.+)$', stdout,
+                              re.MULTILINE)
+            if match:
+                self.ec_version = match.group(1)
+        except:
+            pass
+
+        self.firmware_version = None
+        try:
+            crossystem = subprocess.Popen(['crossystem', 'fwid'],
+                                          stdout=subprocess.PIPE)
+            stdout, _ = crossystem.communicate()
+            self.firmware_version = stdout.strip() or None
+        except:
+            pass
+
+        self.factory_md5sum = factory.get_current_md5sum()
+
+
+class SystemStatus(object):
+    '''Information about the current system status.
+
+    This is information that changes frequently, e.g., load average
+    or battery information.
+    '''
+    def __init__(self):
+        self.battery = {}
+        for k, item_type in [('charge_full', int),
+                             ('charge_full_design', int),
+                             ('charge_now', int),
+                             ('current_now', int),
+                             ('present', bool),
+                             ('status', str),
+                             ('voltage_min_design', int),
+                             ('voltage_now', int)]:
+            try:
+                self.battery[k] = item_type(
+                    open('/sys/class/power_supply/BAT0/%s' % k).read().strip())
+            except:
+                self.battery[k] = None
+
+        try:
+            self.load_avg = map(
+                float, open('/proc/loadavg').read().split()[0:3])
+        except:
+            self.load_avg = None
+
+        try:
+            self.cpu = map(int, open('/proc/stat').readline().split()[1:])
+        except:
+            self.cpu = None
+
+
+if __name__ == '__main__':
+    import yaml
+    print yaml.dump(dict(system_info=SystemInfo(None, None).__dict__,
+                         system_status=SystemStatus().__dict__),
+                    default_flow_style=False)
+
diff --git a/py/goofy/system_unittest.py b/py/goofy/system_unittest.py
new file mode 100644
index 0000000..6121c0f
--- /dev/null
+++ b/py/goofy/system_unittest.py
@@ -0,0 +1,25 @@
+#!/usr/bin/python -u
+#
+# Copyright (c) 2012 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 unittest
+
+import factory_common
+from autotest_lib.client.cros.factory.system import SystemStatus
+
+
+class SystemStatusTest(unittest.TestCase):
+    def runTest(self):
+        # Don't care about the values; just make sure there's something
+        # there.
+        status = SystemStatus()
+        # Don't check battery, since this system might not even have one.
+        self.assertTrue(isinstance(status.battery, dict))
+        self.assertEquals(3, len(status.loadavg))
+        self.assertEquals(10, len(status.cpu))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/py/goofy/test_environment.py b/py/goofy/test_environment.py
new file mode 100644
index 0000000..b387237
--- /dev/null
+++ b/py/goofy/test_environment.py
@@ -0,0 +1,159 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2010 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 cPickle as pickle
+import hashlib
+import logging
+import os
+from Queue import Queue
+import subprocess
+import threading
+import time
+
+import factory_common
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import connection_manager
+from autotest_lib.client.cros.factory import state
+from autotest_lib.client.cros.factory import utils
+
+
+class Environment(object):
+    '''
+    Abstract base class for external test operations, e.g., run an autotest,
+    shutdown, or reboot.
+
+    The Environment is assumed not to be thread-safe: callers must grab the lock
+    before calling any methods.  This is primarily necessary because we mock out
+    this Environment with mox, and unfortunately mox is not thread-safe.
+    TODO(jsalz): Try to write a thread-safe wrapper for mox.
+    '''
+    lock = threading.Lock()
+
+    def shutdown(self, operation):
+        '''
+        Shuts the machine down (from a ShutdownStep).
+
+        Args:
+            operation: 'reboot' or 'halt'.
+
+        Returns:
+            True if Goofy should gracefully exit, or False if Goofy
+                should just consider the shutdown to have suceeded (e.g.,
+                in the chroot).
+        '''
+        raise NotImplementedError()
+
+    def launch_chrome(self):
+        '''
+        Launches Chrome.
+
+        Returns:
+            The Chrome subprocess (or None if none).
+        '''
+        raise NotImplementedError()
+
+    def spawn_autotest(self, name, args, env_additions, result_file):
+        '''
+        Spawns a process to run an autotest.
+
+        Args:
+            name: Name of the autotest to spawn.
+            args: Command-line arguments.
+            env_additions: Additions to the environment.
+            result_file: Expected location of the result file.
+        '''
+        raise NotImplementedError()
+
+    def create_connection_manager(self, wlans):
+        '''
+        Creates a ConnectionManager.
+        '''
+        raise NotImplementedError()
+
+
+class DUTEnvironment(Environment):
+    '''
+    A real environment on a device under test.
+    '''
+    def shutdown(self, operation):
+        assert operation in ['reboot', 'halt']
+        logging.info('Shutting down: %s', operation)
+        subprocess.check_call('sync')
+        subprocess.check_call(operation)
+        time.sleep(30)
+        assert False, 'Never reached (should %s)' % operation
+
+    def spawn_autotest(self, name, args, env_additions, result_file):
+        return self.goofy.prespawner.spawn(args, env_additions)
+
+    def launch_chrome(self):
+        # The cursor speed needs to be adjusted when running in QEMU
+        # (but after Chrome starts and has fiddled with the settings
+        # itself).
+        if utils.in_qemu():
+            def FixCursor():
+                for _ in xrange(6):  # Every 500ms for 3 seconds
+                    time.sleep(.5)
+                    subprocess.check_call(['xset','m','200','200'])
+
+            thread = threading.Thread(target=FixCursor)
+            thread.daemon = True
+            thread.start()
+
+        chrome_command = [
+            '/opt/google/chrome/chrome',
+            '--user-data-dir=%s/factory-chrome-datadir' %
+            factory.get_log_root(),
+            '--disable-translate',
+            '--aura-host-window-use-fullscreen',
+            '--kiosk',
+            ('--default-device-scale-factor=%d' %
+             self.goofy.options.ui_scale_factor),
+            'http://localhost:%d/' % state.DEFAULT_FACTORY_STATE_PORT,
+            ]
+
+        chrome_log = os.path.join(factory.get_log_root(), 'factory.chrome.log')
+        chrome_log_file = open(chrome_log, "a")
+        logging.info('Launching Chrome; logs in %s' % chrome_log)
+        return subprocess.Popen(chrome_command,
+                                stdout=chrome_log_file,
+                                stderr=subprocess.STDOUT)
+
+    def create_connection_manager(self, wlans):
+        return connection_manager.ConnectionManager()
+
+
+class FakeChrootEnvironment(Environment):
+    '''
+    A chroot environment that doesn't actually shutdown or run autotests.
+    '''
+    def shutdown(self, operation):
+        assert operation in ['reboot', 'halt']
+        logging.warn('In chroot: skipping %s', operation)
+        return False
+
+    def spawn_autotest(self, name, args, env_additions, result_file):
+        logging.warn('In chroot: skipping autotest %s', name)
+        # Mark it as passed with 75% probability, or failed with 25%
+        # probability (depending on a hash of the autotest name).
+        pseudo_random = ord(hashlib.sha1(name).digest()[0]) / 256.0
+        passed = pseudo_random > .25
+
+        with open(result_file, 'w') as out:
+            pickle.dump((passed, '' if passed else 'Simulated failure'), out)
+        # Start a process that will return with a true exit status in
+        # 2 seconds (just like a happy autotest).
+        return subprocess.Popen(['sleep', '2'])
+
+    def launch_chrome(self):
+        logging.warn('In chroot; not launching Chrome. '
+                     'Please open http://localhost:%d/ in Chrome.',
+                     state.DEFAULT_FACTORY_STATE_PORT)
+
+    def create_connection_manager(self, wlans):
+        return connection_manager.DummyConnectionManager()
+
+
diff --git a/py/goofy/time_sanitizer.py b/py/goofy/time_sanitizer.py
new file mode 100644
index 0000000..d5db01e
--- /dev/null
+++ b/py/goofy/time_sanitizer.py
@@ -0,0 +1,213 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2012 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 argparse
+import ctypes
+import daemon
+import lockfile
+import logging
+import math
+import optparse
+import os
+import time
+
+import factory_common
+from autotest_lib.client.cros import factory
+
+
+def _FormatTime(t):
+  us, s = math.modf(t)
+  return '%s.%06dZ' % (time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(s)),
+                       int(us * 1000000))
+
+
+librt = ctypes.cdll.LoadLibrary('librt.so')
+class timespec(ctypes.Structure):
+  _fields_ = [('tv_sec', ctypes.c_long),
+              ('tv_nsec', ctypes.c_long)]
+
+
+class Time(object):
+  '''Time object for mocking.'''
+  def Time(self):
+    return time.time()
+
+  def SetTime(self, new_time):
+    logging.warn('Setting time to %s', _FormatTime(new_time))
+    us, s = math.modf(new_time)
+    value = timespec(int(s), int(us * 1000000))
+    librt.clock_settime(0, ctypes.pointer(value))
+
+
+SECONDS_PER_DAY = 86400
+
+
+class TimeSanitizer(object):
+  def __init__(self, state_file,
+               monitor_interval_secs,
+               time_bump_secs,
+               max_leap_secs,
+               base_time=None):
+    '''Attempts to ensure that system time is monotonic and sane.
+
+    Guarantees that:
+
+    - The system time is never less than any other time seen
+      while the monoticizer is alive (even after a reboot).
+    - The system time is never less than base_time, if provided.
+    - The system time never leaps forward more than max_leap_secs,
+      e.g., to a nonsense time like 20 years in the future.
+
+    When an insane time is observed, the current time is set to the
+    last known time plus time_bump_secs.  For this reason
+    time_bump_secs should be greater than monitor_interval_secs.
+
+    Args/Properties:
+      state_file: A file used to store persistent state across
+        reboots (currently just the maximum time ever seen,
+        plus time_bump_secs).
+      monitor_interval_secs: The frequency at which to poll the
+        system clock and ensure sanity.
+      time_bump_secs: How far ahead the time should be moved past
+        the last-seen-good time if an insane time is observed.
+      max_leap_secs: How far ahead the time may increment without
+        being considered insane.
+      base_time: A time that is known to be earlier than the current
+        time.
+    '''
+    self.state_file = state_file
+    self.monitor_interval_secs = monitor_interval_secs
+    self.time_bump_secs = time_bump_secs
+    self.max_leap_secs = max_leap_secs
+    self.base_time = base_time
+
+    # Set time object.  This may be mocked out.
+    self._time = Time()
+
+    if not os.path.isdir(os.path.dirname(self.state_file)):
+      os.makedirs(os.path.dirname(self.state_file))
+
+  def Run(self):
+      '''Runs forever, immediately and then every monitor_interval_secs.'''
+      while True:
+        try:
+          self.RunOnce()
+        except:
+          logging.exception()
+
+        time.sleep(self.monitor_interval_secs)
+
+  def RunOnce(self):
+    '''Runs once, returning immediately.'''
+    minimum_time = self.base_time  # May be None
+    if os.path.exists(self.state_file):
+      try:
+        minimum_time = max(minimum_time,
+                           float(open(self.state_file).read().strip()))
+      except:
+        logging.exception('Unable to read %s', self.state_file)
+    else:
+      logging.warn('State file %s does not exist', self.state_file)
+
+    now = self._time.Time()
+
+    if minimum_time is None:
+      minimum_time = now
+      logging.warn('No minimum time or base time provided; assuming '
+                   'current time (%s) is correct.',
+                   _FormatTime(now))
+    else:
+      sane_time = minimum_time + self.time_bump_secs
+      if now < minimum_time:
+        logging.warn('Current time %s is less than minimum time %s; '
+                     'assuming clock is hosed',
+                     _FormatTime(now), _FormatTime(minimum_time))
+        self._time.SetTime(sane_time)
+        now = sane_time
+      elif now > minimum_time + self.max_leap_secs:
+        logging.warn(
+          'Current time %s is too far past %s; assuming clock is hosed',
+          _FormatTime(now), _FormatTime(minimum_time + self.max_leap_secs))
+        self._time.SetTime(sane_time)
+        now = sane_time
+
+    with open(self.state_file, 'w') as f:
+      logging.debug('Recording current time %s into %s',
+                    _FormatTime(now), self.state_file)
+      print >>f, now
+
+
+def _GetBaseTime(base_time_file):
+  '''Returns the base time to use (the mtime of base_time_file or None).
+
+  Never throws an exception.'''
+  if os.path.exists(base_time_file):
+    try:
+      base_time = os.stat(base_time_file).st_mtime
+      logging.info('Using %s (mtime of %s) as base time',
+                   _FormatTime(base_time), base_time_file)
+      return base_time
+    except:
+      logging.exception('Unable to stat %s', base_time_file)
+  else:
+    logging.warn('base-time-file %s does not exist',
+                 base_time_file)
+  return None
+
+
+def main():
+  parser = argparse.ArgumentParser(description='Ensure sanity of system time.')
+  parser.add_argument('--state-file', metavar='FILE',
+                      default=os.path.join(factory.get_state_root(),
+                                           'time_sanitizer.state'),
+                      help='file to maintain state across reboots')
+  parser.add_argument('--daemon', action='store_true',
+                      help=('run as a daemon (to keep known-good time '
+                            'in state file up to date)')),
+  parser.add_argument('--log', metavar='FILE',
+                      default=os.path.join(factory.get_log_root(),
+                                           'time_sanitizer.log'),
+                      help='log file (if run as a daemon)')
+  parser.add_argument('--monitor-interval-secs', metavar='SECS', type=int,
+                      default=30,
+                      help='period with which to monitor time')
+  parser.add_argument('--time-bump-secs', metavar='SECS', type=int,
+                      default=60,
+                      help=('how far ahead to move the time '
+                            'if the clock is hosed')),
+  parser.add_argument('--max-leap-secs', metavar='SECS', type=int,
+                      default=(SECONDS_PER_DAY * 30),
+                      help=('maximum possible time leap without the clock '
+                            'being considered hosed')),
+  parser.add_argument('--verbose', '-v', action='store_true',
+                      help='verbose log')
+  parser.add_argument('--base-time-file', metavar='FILE',
+                      default='/usr/local/etc/lsb-factory',
+                      help=('a file whose mtime represents the minimum '
+                            'possible time that can ever be seen'))
+  args = parser.parse_args()
+
+  logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
+                      filename=args.log if args.daemon else None)
+
+  sanitizer = TimeSanitizer(os.path.realpath(args.state_file)
+                            if args.state_file else None,
+                            _GetBaseTime(args.base_time_file)
+                            if args.base_time_file else None,
+                            args.monitor_interval_secs,
+                            args.time_bump_secs,
+                            args.max_leap_secs)
+
+  # Make sure we run once (even in daemon mode) before returning.
+  sanitizer.RunOnce()
+
+  if args.daemon:
+    with daemon.DaemonContext():
+      sanitizer.Run()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/py/goofy/time_sanitizer_unittest.py b/py/goofy/time_sanitizer_unittest.py
new file mode 100644
index 0000000..d6e2ee3
--- /dev/null
+++ b/py/goofy/time_sanitizer_unittest.py
@@ -0,0 +1,90 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2012 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 calendar
+import mox
+import os
+import tempfile
+import time
+import unittest
+
+from contextlib import contextmanager
+
+import factory_common
+from autotest_lib.client.cros.factory import time_sanitizer
+
+
+BASE_TIME = float(
+  calendar.timegm(time.strptime('Sat Jun  9 00:00:00 2012')))
+
+SECONDS_PER_DAY = 86400
+
+class TimeSanitizerTest(unittest.TestCase):
+  def testBaseTimeFile(self):
+    with tempfile.NamedTemporaryFile() as f:
+      self.assertEquals(os.stat(f.name).st_mtime,
+                        time_sanitizer._GetBaseTime(f.name))
+    self.assertEquals(None,
+                      time_sanitizer._GetBaseTime('/some-nonexistent-file'))
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.fake_time = self.mox.CreateMock(time_sanitizer.Time)
+
+    self.state_file = tempfile.NamedTemporaryFile().name
+    self.sanitizer = time_sanitizer.TimeSanitizer(
+      self.state_file,
+      monitor_interval_secs=30,
+      time_bump_secs=60,
+      max_leap_secs=SECONDS_PER_DAY)
+    self.sanitizer._time = self.fake_time
+
+  def _ReadStateFile(self):
+    return float(open(self.state_file).read().strip())
+
+  def testSanitizer(self):
+    # Context manager that sets up a mock, then runs the sanitizer once.
+    @contextmanager
+    def mock():
+      self.mox.ResetAll()
+      yield
+      self.mox.ReplayAll()
+      self.sanitizer.RunOnce()
+      self.mox.VerifyAll()
+
+    with mock():
+      self.fake_time.Time().AndReturn(BASE_TIME)
+    self.assertEquals(BASE_TIME, self._ReadStateFile())
+
+    # Now move forward 1 second, and then forward 0 seconds.  Should
+    # be fine.
+    for _ in xrange(2):
+      with mock():
+        self.fake_time.Time().AndReturn(BASE_TIME + 1)
+      self.assertEquals(BASE_TIME + 1, self._ReadStateFile())
+
+    # Now move forward 2 days.  This should be considered hosed, so
+    # the time should be bumped up by time_bump_secs (120).
+    with mock():
+      self.fake_time.Time().AndReturn(BASE_TIME + 2 * SECONDS_PER_DAY)
+      self.fake_time.SetTime(BASE_TIME + 61)
+    self.assertEquals(BASE_TIME + 61, self._ReadStateFile())
+
+    # Move forward a bunch.  Fine.
+    with mock():
+      self.fake_time.Time().AndReturn(BASE_TIME + 201.5)
+    self.assertEquals(BASE_TIME + 201.5, self._ReadStateFile())
+
+    # Jump back 20 seconds.  Not fine!
+    with mock():
+      self.fake_time.Time().AndReturn(BASE_TIME + 181.5)
+      self.fake_time.SetTime(BASE_TIME + 261.5)
+    self.assertEquals(BASE_TIME + 261.5, self._ReadStateFile())
+
+if __name__ == "__main__":
+    unittest.main()
+
+
diff --git a/py/goofy/updater.py b/py/goofy/updater.py
new file mode 100644
index 0000000..b857274
--- /dev/null
+++ b/py/goofy/updater.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2012 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 os
+import shutil
+import subprocess
+from urlparse import urlparse
+import uuid
+
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import shopfloor
+
+
+class UpdaterException(Exception):
+    pass
+
+
+def CheckCriticalFiles(autotest_new_path):
+    '''Raises an exception if certain critical files are missing.'''
+    critical_files = [
+        os.path.join(autotest_new_path, f)
+        for f in ['MD5SUM',
+                  'cros/factory/goofy.py',
+                  'site_tests/factory_Finalize/factory_Finalize.py']]
+    missing_files = [f for f in critical_files
+                     if not os.path.exists(f)]
+    if missing_files:
+        raise UpdaterException(
+            'Aborting update: Missing critical files %r' % missing_files)
+
+
+def RunRsync(*rsync_command):
+    '''Runs rsync with the given command.'''
+    factory.console.info('Running `%s`',
+                         ' '.join(rsync_command))
+    # Run rsync.
+    rsync = subprocess.Popen(rsync_command,
+                             stdout=subprocess.PIPE,
+                             stderr=subprocess.STDOUT)
+    stdout, _ = rsync.communicate()
+    if stdout:
+        factory.console.info('rsync output: %s', stdout)
+    if rsync.returncode:
+        raise UpdaterException('rsync returned status %d; aborting' %
+                               rsync.returncode)
+    factory.console.info('rsync succeeded')
+
+
+def TryUpdate(pre_update_hook=None):
+    '''Attempts to update the autotest directory on the device.
+
+    Atomically replaces the autotest directory with new contents.
+    This routine will always fail in the chroot (to avoid destroying
+    the user's working directory).
+
+    Args:
+        pre_update_hook: A routine to be invoked before the
+            autotest directory is swapped out.
+
+    Returns:
+        True if an update was performed and the machine should be
+        rebooted.
+    '''
+    # On a real device, this will resolve to 'autotest' (since 'client'
+    # is a symlink to that).  In the chroot, this will resolve to the
+    # 'client' directory.
+    autotest_path = factory.CLIENT_PATH
+
+    # Determine whether an update is necessary.
+    md5sum_file = os.path.join(autotest_path, 'MD5SUM')
+    if os.path.exists(md5sum_file):
+        current_md5sum = open(md5sum_file).read().strip()
+    else:
+        current_md5sum = None
+
+    url = shopfloor.get_server_url() or shopfloor.detect_default_server_url()
+    factory.console.info(
+        'Checking for updates at <%s>... (current MD5SUM is %s)',
+        url, current_md5sum)
+
+    shopfloor_client = shopfloor.get_instance(detect=True)
+    new_md5sum = shopfloor_client.GetTestMd5sum()
+    factory.console.info('MD5SUM from server is %s', new_md5sum)
+    if current_md5sum == new_md5sum or new_md5sum is None:
+        factory.console.info('Factory software is up to date')
+        return False
+
+    # An update is necessary.  Construct the rsync command.
+    update_port = shopfloor_client.GetUpdatePort()
+    autotest_new_path = '%s.new' % autotest_path
+    RunRsync(
+        'rsync',
+        '-a', '--delete', '--stats',
+        # Use copies of identical files from the old autotest
+        # as much as possible to save network bandwidth.
+        '--copy-dest=%s' % autotest_path,
+        'rsync://%s:%d/autotest/%s/autotest/' % (
+            urlparse(url).hostname,
+            update_port,
+            new_md5sum),
+        '%s/' % autotest_new_path)
+
+    CheckCriticalFiles(autotest_new_path)
+
+    new_md5sum_path = os.path.join(autotest_new_path, 'MD5SUM')
+    new_md5sum_from_fs = open(new_md5sum_path).read().strip()
+    if new_md5sum != new_md5sum_from_fs:
+        raise UpdaterException(
+            'Unexpected MD5SUM in %s: expected %s but found %s' %
+            new_md5sum_path, new_md5sum, new_md5sum_from_fs)
+
+    if factory.in_chroot():
+        raise UpdaterException('Aborting update: In chroot')
+
+    # Copy over autotest results.
+    autotest_results = os.path.join(autotest_path, 'results')
+    if os.path.exists(autotest_results):
+        RunRsync('rsync', '-a',
+                 autotest_results,
+                 autotest_new_path)
+
+    # Alright, here we go!  This is the point of no return.
+    if pre_update_hook:
+        pre_update_hook()
+    autotest_old_path = '%s.old.%s' % (autotest_path, uuid.uuid4())
+    # If one of these fails, we're screwed.
+    shutil.move(autotest_path, autotest_old_path)
+    shutil.move(autotest_new_path, autotest_path)
+    # Delete the autotest.old tree
+    shutil.rmtree(autotest_old_path, ignore_errors=True)
+    factory.console.info('Update successful')
+    return True
diff --git a/py/goofy/web_socket_manager.py b/py/goofy/web_socket_manager.py
new file mode 100644
index 0000000..6259ce4
--- /dev/null
+++ b/py/goofy/web_socket_manager.py
@@ -0,0 +1,186 @@
+#!/usr/bin/python -u
+#
+# Copyright (c) 2012 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 base64
+import httplib
+import logging
+import subprocess
+import threading
+import ws4py
+
+from hashlib import sha1
+from ws4py.websocket import WebSocket
+
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory.event import Event
+from autotest_lib.client.cros.factory.event import EventClient
+
+
+class WebSocketManager(object):
+    '''Object to manage web sockets for Goofy.
+
+    Brokers between events in the event client infrastructure
+    and on web sockets.  Also tails the console log and sends
+    events on web sockets when new bytes become available.
+
+    Each Goofy instance is associated with a UUID.  When a new web
+    socket is created, we send a hello event on the socket with the
+    current UUID.  If we receive a keepalive event with the wrong
+    UUID, we disconnect the client.  This insures that we are always
+    talking to a client that has a complete picture of our state
+    (i.e., if the server restarts, the client must restart as well).
+    '''
+    def __init__(self, uuid):
+        self.uuid = uuid
+        self.lock = threading.Lock()
+        self.web_sockets = set()
+        self.event_client = None
+        self.tail_process = None
+        self.has_confirmed_socket = threading.Event()
+
+        self.event_client = EventClient(callback=self._handle_event,
+                                        name='WebSocketManager')
+        self.tail_process = subprocess.Popen(
+            ["tail", "-F", factory.CONSOLE_LOG_PATH],
+            stdout=subprocess.PIPE,
+            close_fds=True)
+        self.tail_thread = threading.Thread(target=self._tail_console)
+        self.tail_thread.start()
+        self.closed = False
+
+    def close(self):
+        with self.lock:
+            if self.closed:
+                return
+            self.closed = True
+
+        if self.event_client:
+            self.event_client.close()
+            self.event_client = None
+
+        with self.lock:
+            web_sockets = list(self.web_sockets)
+        for web_socket in web_sockets:
+            web_socket.close_connection()
+
+        if self.tail_process:
+            self.tail_process.kill()
+            self.tail_process.wait()
+        if self.tail_thread:
+            self.tail_thread.join()
+
+    def has_sockets(self):
+        '''Returns true if any web sockets are currently connected.'''
+        with self.lock:
+            return len(self.web_sockets) > 0
+
+    def handle_web_socket(self, request):
+        '''Runs a web socket in the current thread.
+
+        request: A RequestHandler object containing the request.
+        '''
+        def send_error(msg):
+            logging.error('Unable to start WebSocket connection: %s', msg)
+            request.send_response(400, msg)
+
+        encoded_key = request.headers.get('Sec-WebSocket-Key')
+
+        if (request.headers.get('Upgrade') != 'websocket' or
+            request.headers.get('Connection') != 'Upgrade' or
+            not encoded_key):
+            send_error('Missing/unexpected headers in WebSocket request')
+            return
+
+        key = base64.b64decode(encoded_key)
+        # Make sure the key is 16 characters, as required by the
+        # WebSockets spec (RFC6455).
+        if len(key) != 16:
+            send_error('Invalid key length')
+
+        version = request.headers.get('Sec-WebSocket-Version')
+        if not version or version not in [str(x) for x in ws4py.WS_VERSION]:
+            send_error('Unsupported WebSocket version %s' % version)
+            return
+
+        request.send_response(httplib.SWITCHING_PROTOCOLS)
+        request.send_header('Upgrade', 'websocket')
+        request.send_header('Connection', 'Upgrade')
+        request.send_header(
+            'Sec-WebSocket-Accept',
+            base64.b64encode(sha1(encoded_key + ws4py.WS_KEY).digest()))
+        request.end_headers()
+
+        class MyWebSocket(WebSocket):
+            def received_message(socket_self, message):
+                event = Event.from_json(str(message))
+                if event.type == Event.Type.KEEPALIVE:
+                    if event.uuid == self.uuid:
+                        if not self.has_confirmed_socket.is_set():
+                            logging.info('Chrome UI has come up')
+                        self.has_confirmed_socket.set()
+                    else:
+                        logging.warning('Disconnecting web socket with '
+                                        'incorrect UUID')
+                        socket_self.close_connection()
+                else:
+                    self.event_client.post_event(event)
+
+        web_socket = MyWebSocket(sock=request.connection)
+
+        # Add a per-socket lock to use for sending, since ws4py is not
+        # thread-safe.
+        web_socket.send_lock = threading.Lock()
+        with web_socket.send_lock:
+            web_socket.send(Event(Event.Type.HELLO,
+                                  uuid=self.uuid).to_json())
+
+        try:
+            with self.lock:
+                self.web_sockets.add(web_socket)
+            logging.info('Running web socket')
+            web_socket.run()
+            logging.info('Web socket closed gracefully')
+        except:
+            logging.exception('Web socket closed with exception')
+        finally:
+            with self.lock:
+                self.web_sockets.discard(web_socket)
+
+    def wait(self):
+        '''Waits for one socket to connect successfully.'''
+        self.has_confirmed_socket.wait()
+
+    def _tail_console(self):
+        '''Tails the console log, generating an event whenever a new
+        line is available.
+
+        We send this event only to web sockets (not to event clients
+        in general) since only the UI is interested in these log
+        lines.
+        '''
+        while True:
+            line = self.tail_process.stdout.readline()
+            if line == '':
+                break
+            self._handle_event(
+                Event(Event.Type.LOG,
+                      message=line.rstrip("\n")))
+
+    def _handle_event(self, event):
+        '''Sends an event to each open WebSocket client.'''
+        with self.lock:
+            web_sockets = list(self.web_sockets)
+
+        if not web_sockets:
+            return
+
+        event_json = event.to_json()
+        for web_socket in web_sockets:
+            try:
+                with web_socket.send_lock:
+                    web_socket.send(event_json)
+            except:
+                logging.exception('Unable to send event on web socket')
diff --git a/py/test/__init__.py b/py/test/__init__.py
new file mode 100644
index 0000000..1bb8ed6
--- /dev/null
+++ b/py/test/__init__.py
@@ -0,0 +1,789 @@
+# Copyright (c) 2012 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 library provides common types and routines for the factory test
+infrastructure.  This library explicitly does not import gtk, to
+allow its use by the autotest control process.
+
+To log to the factory console, use:
+
+  from autotest_lib.client.cros import factory
+  factory.console.info('...')  # Or warn, or error
+'''
+
+
+import getpass
+import logging
+import os
+import sys
+
+import factory_common
+from autotest_lib.client.cros.factory import connection_manager
+from autotest_lib.client.cros.factory import utils
+
+SCRIPT_PATH = os.path.realpath(__file__)
+CROS_FACTORY_LIB_PATH = os.path.dirname(SCRIPT_PATH)
+CLIENT_PATH = os.path.realpath(os.path.join(CROS_FACTORY_LIB_PATH, '..', '..'))
+
+FACTORY_STATE_VERSION = 2
+
+
+class TestListError(Exception):
+    pass
+
+
+# For compatibility; moved to utils.
+in_chroot = utils.in_chroot
+
+
+def get_log_root():
+    '''Returns the root for logging and state.
+
+    This is usually /var/log, or /tmp/factory.$USER if in the chroot, but may be
+    overridden by the CROS_FACTORY_LOG_ROOT environment variable.
+    '''
+    ret = os.environ.get('CROS_FACTORY_LOG_ROOT')
+    if ret:
+        return ret
+    if in_chroot():
+        return '/tmp/factory.%s' % getpass.getuser()
+    return '/var/log'
+
+
+def get_state_root():
+    '''Returns the root for all factory state.'''
+    return os.path.join(
+        get_log_root(), 'factory_state.v%d' % FACTORY_STATE_VERSION)
+
+
+CONSOLE_LOG_PATH = os.path.join(get_log_root(), 'console.log')
+FACTORY_LOG_PATH = os.path.join(get_log_root(), 'factory.log')
+
+
+_state_instance = None
+
+
+def get_current_test_path():
+    # Returns the path of the currently executing test, if any.
+    return os.environ.get("CROS_FACTORY_TEST_PATH")
+
+
+def get_lsb_data():
+    """Reads all key-value pairs from system lsb-* configuration files."""
+    # TODO(hungte) Re-implement using regex.
+    # lsb-* file format:
+    # [#]KEY="VALUE DATA"
+    lsb_files = ('/etc/lsb-release',
+                 '/usr/local/etc/lsb-release',
+                 '/usr/local/etc/lsb-factory')
+    def unquote(entry):
+        for c in ('"', "'"):
+            if entry.startswith(c) and entry.endswith(c):
+                return entry[1:-1]
+        return entry
+    data = dict()
+    for lsb_file in lsb_files:
+        if not os.path.exists(lsb_file):
+            continue
+        with open(lsb_file, "rt") as lsb_handle:
+            for line in lsb_handle.readlines():
+                line = line.strip()
+                if ('=' not in line) or line.startswith('#'):
+                    continue
+                (key, value) = line.split('=', 1)
+                data[unquote(key)] = unquote(value)
+    return data
+
+
+def get_current_md5sum():
+    '''Returns MD5SUM of the current autotest directory.
+
+    Returns None if there has been no update (i.e., unable to read
+    the MD5SUM file).
+    '''
+    md5sum_file = os.path.join(CLIENT_PATH, 'MD5SUM')
+    if os.path.exists(md5sum_file):
+        return open(md5sum_file, 'r').read().strip()
+    else:
+        return None
+
+
+def _init_console_log():
+    handler = logging.FileHandler(CONSOLE_LOG_PATH, "a", delay=True)
+    log_format = '[%(levelname)s] %(message)s'
+    test_path = get_current_test_path()
+    if test_path:
+        log_format = test_path + ': ' + log_format
+    handler.setFormatter(logging.Formatter(log_format))
+
+    ret = logging.getLogger("console")
+    ret.addHandler(handler)
+    ret.setLevel(logging.INFO)
+    return ret
+
+
+console = _init_console_log()
+
+
+def std_repr(obj, extra=[], excluded_keys=[], true_only=False):
+    '''
+    Returns the representation of an object including its properties.
+
+    @param extra: Extra items to include in the representation.
+    @param excluded_keys: Keys not to include in the representation.
+    @param true_only: Whether to include only values that evaluate to
+        true.
+    '''
+    # pylint: disable=W0102
+    return (obj.__class__.__name__ + '(' +
+            ', '.join(extra +
+                      ['%s=%s' % (k, repr(getattr(obj, k)))
+                       for k in sorted(obj.__dict__.keys())
+                       if k[0] != '_' and k not in excluded_keys and
+                       (not true_only or getattr(obj, k))])
+            + ')')
+
+
+def log(s):
+    '''
+    Logs a message to the console.  Deprecated; use the 'console'
+    property instead.
+
+    TODO(jsalz): Remove references throughout factory tests.
+    '''
+    console.info(s)
+
+
+def get_state_instance():
+    '''
+    Returns a cached factory state client instance.
+    '''
+    # Delay loading modules to prevent circular dependency.
+    import factory_common
+    from autotest_lib.client.cros.factory import state
+    global _state_instance  # pylint: disable=W0603
+    if _state_instance is None:
+        _state_instance = state.get_instance()
+    return _state_instance
+
+
+def get_shared_data(key, default=None):
+    if not get_state_instance().has_shared_data(key):
+        return default
+    return get_state_instance().get_shared_data(key)
+
+
+def set_shared_data(*key_value_pairs):
+    return get_state_instance().set_shared_data(*key_value_pairs)
+
+
+def has_shared_data(key):
+    return get_state_instance().has_shared_data(key)
+
+
+def del_shared_data(key):
+    return get_state_instance().del_shared_data(key)
+
+
+def read_test_list(path=None, state_instance=None, text=None):
+    if len([x for x in [path, text] if x]) != 1:
+        raise TestListError('Exactly one of path and text must be set')
+
+    test_list_locals = {}
+    # Import test classes into the evaluation namespace
+    for (k, v) in dict(globals()).iteritems():
+        if type(v) == type and issubclass(v, FactoryTest):
+            test_list_locals[k] = v
+
+    # Import WLAN into the evaluation namespace, since it is used
+    # to construct the wlans option.
+    test_list_locals['WLAN'] = connection_manager.WLAN
+
+    options = Options()
+    test_list_locals['options'] = options
+
+    if path:
+        execfile(path, {}, test_list_locals)
+    else:
+        exec text in {}, test_list_locals
+    assert 'TEST_LIST' in test_list_locals, (
+        'Test list %s does not define TEST_LIST' % (path or '<text>'))
+
+    options.check_valid()
+
+    return FactoryTestList(test_list_locals['TEST_LIST'],
+                           state_instance or get_state_instance(),
+                           options)
+
+
+_inited_logging = False
+def init_logging(prefix=None, verbose=False):
+    '''
+    Initializes logging.
+
+    @param prefix: A prefix to display for each log line, e.g., the program
+        name.
+    @param verbose: True for debug logging, false for info logging.
+    '''
+    global _inited_logging  # pylint: disable=W0603
+    assert not _inited_logging, "May only call init_logging once"
+    _inited_logging = True
+
+    if not prefix:
+        prefix = os.path.basename(sys.argv[0])
+
+    # Make sure that nothing else has initialized logging yet (e.g.,
+    # autotest, whose logging_config does basicConfig).
+    assert not logging.getLogger().handlers, (
+        "Logging has already been initialized")
+
+    logging.basicConfig(
+        format=('[%(levelname)s] ' + prefix +
+                ' %(filename)s:%(lineno)d %(asctime)s.%(msecs)03d %(message)s'),
+        level=logging.DEBUG if verbose else logging.INFO,
+        datefmt='%Y-%m-%d %H:%M:%S')
+
+    logging.debug('Initialized logging')
+
+
+class Options(object):
+    '''Test list options.
+
+    These may be set by assigning to the options variable in a test list (e.g.,
+    'options.auto_run_on_start = False').
+    '''
+    # Allowable types for an option (defaults to the type of the default
+    # value).
+    _types = {}
+
+    # Perform an implicit auto-run when the test driver starts up?
+    auto_run_on_start = True
+
+    # Perform an implicit auto-run when the user switches to any test?
+    auto_run_on_keypress = False
+
+    # Default UI language
+    ui_lang = 'en'
+
+    # Preserve only autotest results matching these globs.
+    preserve_autotest_results = ['*.DEBUG', '*.INFO']
+
+    # Maximum amount of time allowed between reboots.  If this threshold is
+    # exceeded, the reboot is considered failed.
+    max_reboot_time_secs = 180
+
+    # SHA1 hash for a eng password in UI.  Use None to always
+    # enable eng mode.  To generate, run `echo -n '<password>'
+    # | sha1sum`.
+    engineering_password_sha1 = None
+    _types['engineering_password_sha1'] = (type(None), str)
+
+    # WLANs that network_manager may connect to.
+    wlans = []
+
+    def check_valid(self):
+        '''Throws a TestListError if there are any invalid options.'''
+        # Make sure no errant options, or options with weird types,
+        # were set.
+        default_options = Options()
+        for key in sorted(self.__dict__):
+            if key.startswith('_'):
+                continue
+            if not hasattr(default_options, key):
+                raise TestListError('Unknown option %s' % key)
+
+            value = getattr(self, key)
+            allowable_types = Options._types.get(
+                key,
+                [type(getattr(default_options, key))]);
+            if type(value) not in allowable_types:
+                raise TestListError(
+                    'Option %s has unexpected type %s (should be %s)' % (
+                        key, type(value), allowable_types))
+
+
+class TestState(object):
+    '''
+    The complete state of a test.
+
+    @property status: The status of the test (one of ACTIVE, PASSED,
+        FAILED, or UNTESTED).
+    @property count: The number of times the test has been run.
+    @property error_msg: The last error message that caused a test failure.
+    @property shutdown_count: The next of times the test has caused a shutdown.
+    @property visible: Whether the test is the currently visible test.
+    @property invocation: The currently executing invocation.
+    '''
+    ACTIVE = 'ACTIVE'
+    PASSED = 'PASSED'
+    FAILED = 'FAILED'
+    UNTESTED = 'UNTESTED'
+
+    def __init__(self, status=UNTESTED, count=0, visible=False, error_msg=None,
+                 shutdown_count=0, invocation=None):
+        self.status = status
+        self.count = count
+        self.visible = visible
+        self.error_msg = error_msg
+        self.shutdown_count = shutdown_count
+        self.invocation = None
+
+    def __repr__(self):
+        return std_repr(self)
+
+    def update(self, status=None, increment_count=0, error_msg=None,
+               shutdown_count=None, increment_shutdown_count=0, visible=None,
+               invocation=None):
+        '''
+        Updates the state of a test.
+
+        @param status: The new status of the test.
+        @param increment_count: An amount by which to increment count.
+        @param error_msg: If non-None, the new error message for the test.
+        @param shutdown_count: If non-None, the new shutdown count.
+        @param increment_shutdown_count: An amount by which to increment
+            shutdown_count.
+        @param visible: If non-None, whether the test should become visible.
+        @param invocation: The currently executing invocation, if any.
+            (Applies only if the status is ACTIVE.)
+
+        Returns True if anything was changed.
+        '''
+        old_dict = dict(self.__dict__)
+
+        if status:
+            self.status = status
+        if error_msg is not None:
+            self.error_msg = error_msg
+        if shutdown_count is not None:
+            self.shutdown_count = shutdown_count
+        if visible is not None:
+            self.visible = visible
+
+        if self.status != self.ACTIVE:
+            self.invocation = None
+        elif invocation is not None:
+            self.invocation = invocation
+
+        self.count += increment_count
+        self.shutdown_count += increment_shutdown_count
+
+        return self.__dict__ != old_dict
+
+    @classmethod
+    def from_dict_or_object(cls, obj):
+        if type(obj) == dict:
+            return TestState(**obj)
+        else:
+            assert type(obj) == TestState, type(obj)
+            return obj
+
+
+class FactoryTest(object):
+    '''
+    A factory test object.
+
+    Factory tests are stored in a tree.  Each node has an id (unique
+    among its siblings).  Each node also has a path (unique throughout the
+    tree), constructed by joining the IDs of all the test's ancestors
+    with a '.' delimiter.
+    '''
+
+    # If True, the test never fails, but only returns to an untested state.
+    never_fails = False
+
+    # If True, the test has a UI, so if it is active factory_ui will not
+    # display the summary of running tests.
+    has_ui = False
+
+    REPR_FIELDS = ['id', 'autotest_name', 'pytest_name', 'dargs',
+                   'backgroundable', 'exclusive', 'never_fails']
+
+    # Subsystems that the test may require exclusive access to.
+    EXCLUSIVE_OPTIONS = utils.Enum(['NETWORKING'])
+
+    def __init__(self,
+                 label_en='',
+                 label_zh='',
+                 autotest_name=None,
+                 pytest_name=None,
+                 kbd_shortcut=None,
+                 dargs=None,
+                 backgroundable=False,
+                 subtests=None,
+                 id=None,                  # pylint: disable=W0622
+                 has_ui=None,
+                 never_fails=None,
+                 exclusive=None,
+                 _root=None):
+        '''
+        Constructor.
+
+        @param label_en: An English label.
+        @param label_zh: A Chinese label.
+        @param autotest_name: The name of the autotest to run.
+        @param pytest_name: The name of the pytest to run (relative to
+            autotest_lib.client.cros.factory.tests).
+        @param kbd_shortcut: The keyboard shortcut for the test.
+        @param dargs: Autotest arguments.
+        @param backgroundable: Whether the test may run in the background.
+        @param subtests: A list of tests to run inside this test.
+        @param id: A unique ID for the test (defaults to the autotest name).
+        @param has_ui: True if the test has a UI.  (This defaults to True for
+            OperatorTest.)  If has_ui is not True, then when the test is
+            running, the statuses of the test and its siblings will be shown in
+            the test UI area instead.
+        @param never_fails: True if the test never fails, but only returns to an
+            untested state.
+        @param exclusive: Items that the test may require exclusive access to.
+            May be a list or a single string.  Items must all be in
+            EXCLUSIVE_OPTIONS.  Tests may not be backgroundable.
+        @param _root: True only if this is the root node (for internal use
+            only).
+        '''
+        self.label_en = label_en
+        self.label_zh = label_zh
+        self.autotest_name = autotest_name
+        self.pytest_name = pytest_name
+        self.kbd_shortcut = kbd_shortcut.lower() if kbd_shortcut else None
+        self.dargs = dargs or {}
+        self.backgroundable = backgroundable
+        if isinstance(exclusive, str):
+            self.exclusive = [exclusive]
+        else:
+            self.exclusive = exclusive or []
+        self.subtests = subtests or []
+        self.path = ''
+        self.parent = None
+        self.root = None
+
+        assert not (autotest_name and pytest_name), (
+            'No more than one of autotest_name, pytest_name must be specified')
+
+        if _root:
+            self.id = None
+        else:
+            self.id = id or autotest_name or pytest_name.rpartition('.')[2]
+            assert self.id, (
+                'Tests require either an id or autotest name: %r' % self)
+            assert '.' not in self.id, (
+                'id cannot contain a period: %r' % self)
+            # Note that we check ID uniqueness in _init.
+
+        if has_ui is not None:
+            self.has_ui = has_ui
+        if never_fails is not None:
+            self.never_fails = never_fails
+
+        # Auto-assign label text.
+        if not self.label_en:
+            if self.id and (self.id != self.autotest_name):
+                self.label_en = self.id
+            elif self.autotest_name:
+                # autotest_name is type_NameInCamelCase.
+                self.label_en = self.autotest_name.partition('_')[2]
+
+        assert not (backgroundable and exclusive), (
+            'Test %s may not have both backgroundable and exclusive' %
+            self.id)
+        bogus_exclusive_items = set(self.exclusive) - self.EXCLUSIVE_OPTIONS
+        assert not bogus_exclusive_items, (
+            'In test %s, invalid exclusive options: %s (should be in %s)' % (
+                self.id,
+                bogus_exclusive_items,
+                self.EXCLUSIVE_OPTIONS))
+
+        assert not ((autotest_name or pytest_name) and self.subtests), (
+            'Test %s may not have both an autotest and subtests' % self.id)
+
+    def to_struct(self):
+        '''Returns the node as a struct suitable for JSONification.'''
+        ret = dict(
+            (k, getattr(self, k))
+            for k in ['id', 'path', 'label_en', 'label_zh',
+                      'kbd_shortcut', 'backgroundable'])
+        ret['subtests'] = [subtest.to_struct() for subtest in self.subtests]
+        return ret
+
+
+    def __repr__(self, recursive=False):
+        attrs = ['%s=%s' % (k, repr(getattr(self, k)))
+                 for k in sorted(self.__dict__.keys())
+                 if k in FactoryTest.REPR_FIELDS and getattr(self, k)]
+        if recursive and self.subtests:
+            indent = '    ' * (1 + self.path.count('.'))
+            attrs.append(
+                'subtests=[' +
+                ('\n' +
+                 ',\n'.join([subtest.__repr__(recursive)
+                             for subtest in self.subtests]
+                            )).replace('\n', '\n' + indent)
+                + '\n]')
+
+        return '%s(%s)' % (self.__class__.__name__, ', '.join(attrs))
+
+    def _init(self, prefix, path_map):
+        '''
+        Recursively assigns paths to this node and its children.
+
+        Also adds this node to the root's path_map.
+        '''
+        if self.parent:
+            self.root = self.parent.root
+
+        self.path = prefix + (self.id or '')
+        assert self.path not in path_map, 'Duplicate test path %s' % (self.path)
+        path_map[self.path] = self
+
+        for subtest in self.subtests:
+            subtest.parent = self
+            subtest._init((self.path + '.' if len(self.path) else ''), path_map)
+
+    def depth(self):
+        '''
+        Returns the depth of the node (0 for the root).
+        '''
+        return self.path.count('.') + (self.parent is not None)
+
+    def is_leaf(self):
+        '''
+        Returns true if this is a leaf node.
+        '''
+        return not self.subtests
+
+    def get_ancestors(self):
+      '''
+      Returns list of ancestors, ordered by seniority.
+      '''
+      if self.parent is not None:
+        return self.parent.get_ancestors() + [self.parent]
+      return []
+
+    def get_ancestor_groups(self):
+      '''
+      Returns list of ancestors that are groups, ordered by seniority.
+      '''
+      return [node for node in self.get_ancestors() if node.is_group()]
+
+    def get_state(self):
+        '''
+        Returns the current test state from the state instance.
+        '''
+        return TestState.from_dict_or_object(
+            self.root.state_instance.get_test_state(self.path))
+
+    def update_state(self, update_parent=True, status=None, **kw):
+        '''
+        Updates the test state.
+
+        See TestState.update for allowable kw arguments.
+        '''
+        if self.never_fails and status == TestState.FAILED:
+            status = TestState.UNTESTED
+
+        ret = TestState.from_dict_or_object(
+            self.root._update_test_state(  # pylint: disable=W0212
+                self.path, status=status, **kw))
+        if update_parent and self.parent:
+            self.parent.update_status_from_children()
+        return ret
+
+    def update_status_from_children(self):
+        '''
+        Updates the status based on children's status.
+
+        A test is active if any children are active; else failed if
+        any children are failed; else untested if any children are
+        untested; else passed.
+        '''
+        if not self.subtests:
+            return
+
+        statuses = set([x.get_state().status for x in self.subtests])
+
+        # If there are any active tests, consider it active; if any failed,
+        # consider it failed, etc.  The order is important!
+        # pylint: disable=W0631
+        for status in [TestState.ACTIVE, TestState.FAILED,
+                       TestState.UNTESTED, TestState.PASSED]:
+            if status in statuses:
+                break
+
+        if status != self.get_state().status:
+            self.update_state(status=status)
+
+    def walk(self, in_order=False):
+        '''
+        Yields this test and each sub-test.
+
+        @param in_order: Whether to walk in-order.  If False, walks depth-first.
+        '''
+        if in_order:
+            # Walking in order - yield self first.
+            yield self
+        for subtest in self.subtests:
+            for f in subtest.walk(in_order):
+                yield f
+        if not in_order:
+            # Walking depth first - yield self last.
+            yield self
+
+    def is_group(self):
+        '''
+        Returns true if this node is a test group.
+        '''
+        return isinstance(self, TestGroup)
+
+    def is_top_level_test(self):
+        '''
+        Returns true if this node is a top-level test.
+
+        A 'top-level test' is a test directly underneath the root or a
+        TestGroup, e.g., a node under which all tests must be run
+        together to be meaningful.
+        '''
+        return ((not self.is_group()) and
+                self.parent and
+                (self.parent == self.root or self.parent.is_group()))
+
+    def get_top_level_parent_or_group(self):
+        if self.is_group() or self.is_top_level_test() or not self.parent:
+            return self
+        return self.parent.get_top_level_parent_or_group()
+
+    def get_top_level_tests(self):
+        '''
+        Returns a list of top-level tests.
+        '''
+        return [node for node in self.walk()
+                if node.is_top_level_test()]
+
+    def is_exclusive(self, option):
+        '''
+        Returns true if the test or any parent is exclusive w.r.t. option.
+
+        Args:
+          option: A member of EXCLUSIVE_OPTIONS.
+        '''
+        assert option in self.EXCLUSIVE_OPTIONS
+        return option in self.exclusive or (
+            self.parent and self.parent.is_exclusive(option))
+
+
+class FactoryTestList(FactoryTest):
+    '''
+    The root node for factory tests.
+
+    Properties:
+        path_map: A map from test paths to FactoryTest objects.
+    '''
+    def __init__(self, subtests, state_instance, options):
+        super(FactoryTestList, self).__init__(_root=True, subtests=subtests)
+        self.state_instance = state_instance
+        self.subtests = subtests
+        self.path_map = {}
+        self.root = self
+        self.state_change_callback = None
+        self.options = options
+        self._init('', self.path_map)
+
+    def get_all_tests(self):
+        '''
+        Returns all FactoryTest objects.
+        '''
+        return self.path_map.values()
+
+    def get_state_map(self):
+        '''
+        Returns a map of all FactoryTest objects to their TestStates.
+        '''
+        # The state instance may return a dict (for the XML/RPC proxy)
+        # or the TestState object itself.  Convert accordingly.
+        return dict(
+            (self.lookup_path(k), TestState.from_dict_or_object(v))
+            for k, v in self.state_instance.get_test_states().iteritems())
+
+    def lookup_path(self, path):
+        '''
+        Looks up a test from its path.
+        '''
+        return self.path_map.get(path, None)
+
+    def _update_test_state(self, path, **kw):
+        '''
+        Updates a test state, invoking the state_change_callback if any.
+
+        Internal-only; clients should call update_state directly on the
+        appropriate TestState object.
+        '''
+        ret, changed = self.state_instance.update_test_state(path, **kw)
+        if changed and self.state_change_callback:
+            self.state_change_callback(  # pylint: disable=E1102
+                self.lookup_path(path), ret)
+        return ret
+
+
+class TestGroup(FactoryTest):
+    '''
+    A collection of related tests, shown together in RHS panel if one is active.
+    '''
+    pass
+
+
+class FactoryAutotestTest(FactoryTest):
+    pass
+
+
+class OperatorTest(FactoryAutotestTest):
+    has_ui = True
+
+
+AutomatedSequence = FactoryTest
+AutomatedSubTest = FactoryAutotestTest
+
+
+class ShutdownStep(AutomatedSubTest):
+    '''A shutdown (halt or reboot) step.
+
+    Properties:
+        iterations: The number of times to reboot.
+        operation: The command to run to perform the shutdown
+            (REBOOT or HALT).
+        delay_secs: Number of seconds the operator has to abort the shutdown.
+    '''
+    REBOOT = 'reboot'
+    HALT = 'halt'
+
+    def __init__(self, operation, iterations=1, delay_secs=5, **kw):
+        kw.setdefault('id', operation)
+        super(ShutdownStep, self).__init__(**kw)
+        assert not self.autotest_name, (
+            'Reboot/halt steps may not have an autotest')
+        assert not self.subtests, 'Reboot/halt steps may not have subtests'
+        assert not self.backgroundable, (
+            'Reboot/halt steps may not be backgroundable')
+
+        assert iterations > 0
+        self.iterations = iterations
+        assert operation in [self.REBOOT, self.HALT]
+        self.operation = operation
+        assert delay_secs >= 0
+        self.delay_secs = delay_secs
+
+
+class HaltStep(ShutdownStep):
+    '''Halts the machine.'''
+    def __init__(self, **kw):
+        super(HaltStep, self).__init__(operation=ShutdownStep.HALT, **kw)
+
+
+class RebootStep(ShutdownStep):
+    '''Reboots the machine.'''
+    def __init__(self, **kw):
+        super(RebootStep, self).__init__(operation=ShutdownStep.REBOOT, **kw)
+
+
+AutomatedRebootSubTest = RebootStep
diff --git a/py/test/event.py b/py/test/event.py
new file mode 100644
index 0000000..58c3900
--- /dev/null
+++ b/py/test/event.py
@@ -0,0 +1,447 @@
+# Copyright (c) 2011 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 cPickle as pickle
+import errno
+import json
+import logging
+import os
+import socket
+import SocketServer
+import sys
+import tempfile
+import threading
+import time
+import traceback
+import types
+from Queue import Empty, Queue
+
+import factory_common
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory.unicode_to_string import UnicodeToString
+
+
+# Environment variable storing the path to the endpoint.
+CROS_FACTORY_EVENT = 'CROS_FACTORY_EVENT'
+
+# Maximum allowed size for messages.  If messages are bigger than this, they
+# will be truncated by the seqpacket sockets.
+_MAX_MESSAGE_SIZE = 65535
+
+
+def json_default_repr(obj):
+    '''Converts an object into a suitable representation for
+    JSON-ification.
+
+    If obj is an object, this returns a dict with all properties
+    not beginning in '_'.  Otherwise, the original object is
+    returned.
+    '''
+    if isinstance(obj, object):
+        return dict([(k,v) for k, v in obj.__dict__.iteritems()
+                     if k[0] != "_"])
+    else:
+        return obj
+
+
+class Event(object):
+    '''
+    An event object that may be written to the event server.
+
+    E.g.:
+
+        event = Event(Event.Type.STATE_CHANGE,
+                      test='foo.bar',
+                      state=TestState(...))
+    '''
+    Type = type('Event.Type', (), {
+            # The state of a test has changed.
+            'STATE_CHANGE':          'goofy:state_change',
+            # The UI has come up.
+            'UI_READY':              'goofy:ui_ready',
+            # Tells goofy to switch to a new test.
+            'SWITCH_TEST':           'goofy:switch_test',
+            # Tells goofy to rotate visibility to the next active test.
+            'SHOW_NEXT_ACTIVE_TEST': 'goofy:show_next_active_test',
+            # Tells goofy to show a particular test.
+            'SET_VISIBLE_TEST':      'goofy:set_visible_test',
+            # Tells goofy to clear all state and restart testing.
+            'RESTART_TESTS':  'goofy:restart_tests',
+            # Tells goofy to run all tests that haven't been run yet.
+            'AUTO_RUN': 'goofy:auto_run',
+            # Tells goofy to set all failed tests' state to untested and re-run.
+            'RE_RUN_FAILED': 'goofy:re_run_failed',
+            # Tells goofy to re-run all tests with particular statuses.
+            'RUN_TESTS_WITH_STATUS': 'goofy:run_tests_with_status',
+            # Tells goofy to go to the review screen.
+            'REVIEW': 'goofy:review',
+            # Tells the UI about a single new line in the log.
+            'LOG': 'goofy:log',
+            # A hello message to a new WebSocket.  Contains a 'uuid' parameter
+            # identification the particular invocation of the server.
+            'HELLO': 'goofy:hello',
+            # A keepalive message from the UI.  Contains a 'uuid' parameter
+            # containing the same 'uuid' value received when the client received
+            # its HELLO.
+            'KEEPALIVE': 'goofy:keepalive',
+            # Sets the UI in the test pane.
+            'SET_HTML': 'goofy:set_html',
+            # Runs JavaScript in the test pane.
+            'RUN_JS': 'goofy:run_js',
+            # Calls a JavaScript function in the test pane.
+            'CALL_JS_FUNCTION': 'goofy:call_js_function',
+            # Event from a test UI.
+            'TEST_UI_EVENT': 'goofy:test_ui_event',
+            # Message from the test UI that it has finished.
+            'END_TEST': 'goofy:end_test',
+            # Message to tell the test UI to destroy itself.
+            'DESTROY_TEST': 'goofy:destroy_test',
+            # Message telling Goofy should re-read system info.
+            'UPDATE_SYSTEM_INFO': 'goofy:update_system_info',
+            # Message containing new system info from Goofy.
+            'SYSTEM_INFO': 'goofy:system_info',
+            # Tells Goofy to stop all tests and update factory
+            # software.
+            'UPDATE_FACTORY': 'goofy:update_factory',
+            # Tells Goofy to stop all tests.
+            'STOP': 'goofy:stop',
+            # Indicates a pending shutdown.
+            'PENDING_SHUTDOWN': 'goofy:pending_shutdown',
+            # Cancels a pending shutdown.
+            'CANCEL_SHUTDOWN': 'goofy:cancel_shutdown',
+            })
+
+    def __init__(self, type, **kw):  # pylint: disable=W0622
+        self.type = type
+        self.timestamp = time.time()
+        for k, v in kw.iteritems():
+            setattr(self, k, v)
+
+    def __repr__(self):
+        return factory.std_repr(
+            self,
+            extra=[
+                'type=%s' % self.type,
+                'timestamp=%s' % time.ctime(self.timestamp)],
+            excluded_keys=['type', 'timestamp'])
+
+    def to_json(self):
+        return json.dumps(self, default=json_default_repr)
+
+    @staticmethod
+    def from_json(encoded_event):
+        kw = UnicodeToString(json.loads(encoded_event))
+        type = kw.pop('type')
+        return Event(type=type, **kw)
+
+_unique_id_lock = threading.Lock()
+_unique_id = 1
+def get_unique_id():
+    global _unique_id
+    with _unique_id_lock:
+        ret = _unique_id
+        _unique_id += 1
+    return ret
+
+
+class EventServerRequestHandler(SocketServer.BaseRequestHandler):
+    '''
+    Request handler for the event server.
+
+    This class is agnostic to message format (except for logging).
+    '''
+    # pylint: disable=W0201,W0212
+    def setup(self):
+        SocketServer.BaseRequestHandler.setup(self)
+        threading.current_thread().name = (
+            'EventServerRequestHandler-%d' % get_unique_id())
+        # A thread to be used to send messages that are posted to the queue.
+        self.send_thread = None
+        # A queue containing messages.
+        self.queue = Queue()
+
+    def handle(self):
+        # The handle() methods is run in a separate thread per client
+        # (since EventServer has ThreadingMixIn).
+        logging.debug('Event server: handling new client')
+        try:
+            self.server._subscribe(self.queue)
+
+            self.send_thread = threading.Thread(
+                target=self._run_send_thread,
+                name='EventServerSendThread-%d' % get_unique_id())
+            self.send_thread.daemon = True
+            self.send_thread.start()
+
+            # Process events: continuously read message and broadcast to all
+            # clients' queues.
+            while True:
+                msg = self.request.recv(_MAX_MESSAGE_SIZE + 1)
+                if len(msg) > _MAX_MESSAGE_SIZE:
+                    logging.error('Event server: message too large')
+                if len(msg) == 0:
+                    break  # EOF
+                self.server._post_message(msg)
+        except socket.error, e:
+            if e.errno in [errno.ECONNRESET, errno.ESHUTDOWN]:
+                pass  # Client just quit
+            else:
+                raise e
+        finally:
+            logging.debug('Event server: client disconnected')
+            self.queue.put(None)  # End of stream; make writer quit
+            self.server._unsubscribe(self.queue)
+
+    def _run_send_thread(self):
+        while True:
+            message = self.queue.get()
+            if message is None:
+                return
+            try:
+                self.request.send(message)
+            except:  # pylint: disable=W0702
+                return
+
+
+class EventServer(SocketServer.ThreadingUnixStreamServer):
+    '''
+    An event server that broadcasts messages to all clients.
+
+    This class is agnostic to message format (except for logging).
+    '''
+    allow_reuse_address = True
+    socket_type = socket.SOCK_SEQPACKET
+    daemon_threads = True
+
+    def __init__(self, path=None):
+        '''
+        Constructor.
+
+        @param path: Path at which to create a UNIX stream socket.
+            If None, uses a temporary path and sets the CROS_FACTORY_EVENT
+            environment variable for future clients to use.
+        '''
+        # A set of queues listening to messages.
+        self._queues = set()
+        # A lock guarding the _queues variable.
+        self._lock = threading.Lock()
+        if not path:
+            path = tempfile.mktemp(prefix='cros_factory_event.')
+            os.environ[CROS_FACTORY_EVENT] = path
+            logging.info('Setting %s=%s', CROS_FACTORY_EVENT, path)
+        SocketServer.UnixStreamServer.__init__(  # pylint: disable=W0233
+            self, path, EventServerRequestHandler)
+
+    def _subscribe(self, queue):
+        '''
+        Subscribes a queue to receive events.
+
+        Invoked only from the request handler.
+        '''
+        with self._lock:
+            self._queues.add(queue)
+
+    def _unsubscribe(self, queue):
+        '''
+        Unsubscribes a queue to receive events.
+
+        Invoked only from the request handler.
+        '''
+        with self._lock:
+            self._queues.discard(queue)
+
+    def _post_message(self, message):
+        '''
+        Posts a message to all clients.
+
+        Invoked only from the request handler.
+        '''
+        try:
+            if logging.getLogger().isEnabledFor(logging.DEBUG):
+                logging.debug('Event server: dispatching object %s',
+                              pickle.loads(message))
+        except:  # pylint: disable=W0702
+            # Message isn't parseable as a pickled object; weird!
+            logging.info(
+                'Event server: dispatching message %r', message)
+
+        with self._lock:
+            for q in self._queues:
+                # Note that this is nonblocking (even if one of the
+                # clients is dead).
+                q.put(message)
+
+
+class EventClient(object):
+    EVENT_LOOP_GOBJECT_IDLE = 'EVENT_LOOP_GOBJECT_IDLE'
+    EVENT_LOOP_GOBJECT_IO = 'EVENT_LOOP_GOBJECT_IO'
+
+    '''
+    A client used to post and receive messages from an event server.
+
+    All events sent through this class must be subclasses of Event.  It
+    marshals Event classes through the server by pickling them.
+    '''
+    def __init__(self, path=None, callback=None, event_loop=None, name=None):
+        '''
+        Constructor.
+
+        @param path: The UNIX seqpacket socket endpoint path.  If None, uses
+            the CROS_FACTORY_EVENT environment variable.
+        @param callback: A callback to call when events occur.  The callback
+            takes one argument: the received event.
+        @param event_loop: An event loop to use to post the events.  May be one
+            of:
+
+            - A Queue object, in which case a lambda invoking the callback is
+              written to the queue.
+            - EVENT_LOOP_GOBJECT_IDLE, in which case the callback will be
+              invoked in the gobject event loop using idle_add.
+            - EVENT_LOOP_GOBJECT_IO, in which case the callback will be
+              invoked from an async IO handler.
+        @param name: An optional name for the client
+        '''
+        self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
+        self.callbacks = set()
+        logging.debug('Initializing event client')
+
+        should_start_thread = True
+
+        path = path or os.environ[CROS_FACTORY_EVENT]
+        self.socket.connect(path)
+        self._lock = threading.Lock()
+
+        if callback:
+            if isinstance(event_loop, Queue):
+                self.callbacks.add(
+                    lambda event: event_loop.put(
+                        lambda: callback(event)))
+            elif event_loop == self.EVENT_LOOP_GOBJECT_IDLE:
+                import gobject
+                self.callbacks.add(
+                    lambda event: gobject.idle_add(callback, event))
+            elif event_loop == self.EVENT_LOOP_GOBJECT_IO:
+                import gobject
+                should_start_thread = False
+                gobject.io_add_watch(
+                    self.socket, gobject.IO_IN,
+                    lambda source, condition: self._read_one_message())
+                self.callbacks.add(callback)
+            else:
+                self.callbacks.add(callback)
+
+        if should_start_thread:
+            self.recv_thread = threading.Thread(
+                target=self._run_recv_thread,
+                name='EventServerRecvThread-%s' % (name or get_unique_id()))
+            self.recv_thread.daemon = True
+            self.recv_thread.start()
+        else:
+            self.recv_thread = None
+
+    def close(self):
+        '''Closes the client, waiting for any threads to terminate.'''
+        if not self.socket:
+            return
+
+        # Shutdown the socket to cause recv_thread to terminate.
+        self.socket.shutdown(socket.SHUT_RDWR)
+        if self.recv_thread:
+            self.recv_thread.join()
+        self.socket.close()
+        self.socket = None
+
+    def __del__(self):
+        self.close()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        try:
+            self.close()
+        except:
+            pass
+        return False
+
+    def post_event(self, event):
+        '''
+        Posts an event to the server.
+        '''
+        logging.debug('Event client: sending event %s', event)
+        message = pickle.dumps(event, protocol=2)
+        if len(message) > _MAX_MESSAGE_SIZE:
+            # Log it first so we know what event caused the problem.
+            logging.error("Message too large (%d bytes): event is %s" %
+                          (len(message), event))
+            raise IOError("Message too large (%d bytes)" % len(message))
+        self.socket.sendall(message)
+
+    def wait(self, condition, timeout=None):
+        '''
+        Waits for an event matching a condition.
+
+        @param condition: A function to evaluate.  The function takes one
+            argument (an event to evaluate) and returns whether the condition
+            applies.
+        @param timeout: A timeout in seconds.  wait will return None on
+            timeout.
+        '''
+        queue = Queue()
+
+        def check_condition(event):
+            if condition(event):
+                queue.put(event)
+
+        try:
+            with self._lock:
+                self.callbacks.add(check_condition)
+            return queue.get(timeout=timeout)
+        except Empty:
+            return None
+        finally:
+            with self._lock:
+                self.callbacks.remove(check_condition)
+
+    def _run_recv_thread(self):
+        '''
+        Thread to receive messages and broadcast them to callbacks.
+        '''
+        while self._read_one_message():
+            pass
+
+    def _read_one_message(self):
+        '''
+        Handles one incomming message from the socket.
+
+        @return: True if event processing should continue (i.e., not EOF).
+        '''
+        bytes = self.socket.recv(_MAX_MESSAGE_SIZE + 1)
+        if len(bytes) > _MAX_MESSAGE_SIZE:
+            # The message may have been truncated - ignore it
+            logging.error('Event client: message too large')
+            return True
+
+        if len(bytes) == 0:
+            return False
+
+        try:
+            event = pickle.loads(bytes)
+            logging.debug('Event client: dispatching event %s', event)
+        except:
+            logging.warn('Event client: bad message %r', bytes)
+            traceback.print_exc(sys.stderr)
+            return True
+
+        with self._lock:
+            callbacks = list(self.callbacks)
+        for callback in callbacks:
+            try:
+                callback(event)
+            except:
+                logging.warn('Event client: error in callback')
+                traceback.print_exc(sys.stderr)
+                # Keep going
+
+        return True
diff --git a/py/test/factory_common.py b/py/test/factory_common.py
new file mode 100644
index 0000000..6e1113e
--- /dev/null
+++ b/py/test/factory_common.py
@@ -0,0 +1,15 @@
+# Copyright (c) 2010 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.
+
+
+'''
+Prepares autotest-compatible namespace for ChromeOS factory framework.
+'''
+
+import os, sys
+
+# Load 'common' module from autotest/bin folder, for 'autotest_bin' namespace.
+sys.path.insert(0, os.path.realpath(
+        os.path.join(os.path.dirname(__file__), '..', '..', 'bin')))
+import common
diff --git a/py/test/factory_unittest.py b/py/test/factory_unittest.py
new file mode 100755
index 0000000..ee262b4
--- /dev/null
+++ b/py/test/factory_unittest.py
@@ -0,0 +1,83 @@
+#!/usr/bin/python -u
+#
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2012 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 factory_common
+
+import glob
+import logging
+import os
+import traceback
+import unittest
+
+from autotest_lib.client.cros import factory
+
+
+FACTORY_DIR = os.path.dirname(os.path.realpath(__file__))
+
+
+class FactoryTest(unittest.TestCase):
+    def test_parse_test_lists(self):
+        '''Checks that all known test lists are parseable.'''
+        # This test is located in a full source checkout (e.g.,
+        # src/third_party/autotest/files/client/cros/factory/
+        # factory_unittest.py).  Construct the paths to the reference test list
+        # and any test lists in private overlays.
+        test_lists = [
+            os.path.join(FACTORY_DIR,
+                         '../../site_tests/suite_Factory/test_list.all')
+            ]
+
+        # Go up six directories to find the top-level source directory.
+        src_dir = os.path.join(FACTORY_DIR, *(['..'] * 6))
+        test_lists.extend(os.path.realpath(x) for x in glob.glob(
+                os.path.join(src_dir, 'private-overlays/*/'
+                             'chromeos-base/autotest-private-board/'
+                             'files/test_list*')))
+
+        failures = []
+        for test_list in test_lists:
+            logging.info('Parsing test list %s', test_list)
+            try:
+                factory.read_test_list(test_list)
+            except:
+                failures.append(test_list)
+                traceback.print_exc()
+
+        if failures:
+            self.fail('Errors in test lists: %r' % failures)
+
+        self.assertEqual([], failures)
+
+    def test_options(self):
+        base_test_list = 'TEST_LIST = []\n'
+
+        # This is a valid option.
+        factory.read_test_list(
+            text=base_test_list +
+            'options.auto_run_on_start = True')
+
+        try:
+            factory.read_test_list(
+                text=base_test_list + 'options.auto_run_on_start = 3')
+            self.fail('Expected exception')
+        except factory.TestListError as e:
+            self.assertTrue(
+                'Option auto_run_on_start has unexpected type' in e[0], e)
+
+        try:
+            factory.read_test_list(
+                text=base_test_list + 'options.fly_me_to_the_moon = 3')
+            self.fail('Expected exception')
+        except factory.TestListError as e:
+            # Sorry, swinging among the stars is currently unsupported.
+            self.assertTrue(
+                'Unknown option fly_me_to_the_moon' in e[0], e)
+
+if __name__ == "__main__":
+    factory.init_logging('factory_unittest')
+    unittest.main()
diff --git a/py/test/gooftools.py b/py/test/gooftools.py
new file mode 100644
index 0000000..7e0f0a6
--- /dev/null
+++ b/py/test/gooftools.py
@@ -0,0 +1,85 @@
+# Copyright (c) 2010 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.
+
+
+"""Wrapper for Google Factory Tools (gooftool).
+
+This module provides fast access to "gooftool".
+"""
+
+
+import os
+import glob
+import subprocess
+import sys
+import tempfile
+
+import factory_common
+from autotest_lib.client.common_lib import error
+from autotest_lib.client.cros import factory
+
+
+GOOFTOOL_HOME = '/usr/local/factory'
+
+
+def run(command, ignore_status=False):
+    """Runs a gooftool command.
+
+    Args:
+        command: Shell command to execute.
+        ignore_status: False to raise exectopion when execution result is not 0.
+
+    Returns:
+        (stdout, stderr, return_code) of the execution results.
+
+    Raises:
+        error.TestError: The error message in "ERROR:.*" form by command.
+    """
+
+    factory.log("Running gooftool: " + command)
+
+    # We want the stderr goes to CONSOLE_LOG_PATH immediately, but tee only
+    # works with stdout; so here's a tiny trick to swap the handles.
+    swap_stdout_stderr = '3>&1 1>&2 2>&3'
+
+    # When using pipes, return code is from the last command; so we need to use
+    # a temporary file for the return code of first command.
+    return_code_file = tempfile.NamedTemporaryFile()
+    system_cmd = ('(PATH=%s:$PATH %s %s || echo $? >"%s") | tee -a "%s"' %
+                  (GOOFTOOL_HOME, command, swap_stdout_stderr,
+                   return_code_file.name, factory.CONSOLE_LOG_PATH))
+    proc = subprocess.Popen(system_cmd,
+                            stderr=subprocess.PIPE,
+                            stdout=subprocess.PIPE,
+                            shell=True)
+
+    # The order of output is reversed because we swapped stdout and stderr.
+    (err, out) = proc.communicate()
+
+    # normalize output data
+    out = out or ''
+    err = err or ''
+    if out.endswith('\n'):
+        out = out[:-1]
+    if err.endswith('\n'):
+        err = err[:-1]
+
+    # build return code and log results
+    return_code_file.seek(0)
+    return_code = int(return_code_file.read() or '0')
+    return_code_file.close()
+    return_code = proc.wait() or return_code
+    message = ('gooftool result: %s (%s), message: %s' %
+               (('FAILED' if return_code else 'SUCCESS'),
+                return_code, '\n'.join([out,err]) or '(None)'))
+    factory.log(message)
+
+    if return_code and (not ignore_status):
+        # try to parse "ERROR.*" from err & out.
+        exception_message = '\n'.join(
+                [error_message for error_message in err.splitlines()
+                 if error_message.startswith('ERROR')]) or message
+        raise error.TestError(exception_message)
+
+    return (out, err, return_code)
diff --git a/py/test/leds.py b/py/test/leds.py
new file mode 100644
index 0000000..0e4a913
--- /dev/null
+++ b/py/test/leds.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python -u
+
+# Copyright (c) 2012 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.
+
+'''
+Routines to modify keyboard LED state.
+'''
+
+import fcntl
+import logging
+import os
+import sys
+import threading
+
+import factory_common
+from autotest_lib.client.bin import utils
+
+
+# Constants from /usr/include/linux/kd.h.
+KDSETLED = 0x4B32
+LED_SCR = 1
+LED_NUM = 2
+LED_CAP = 4
+
+# Resets LEDs to the default values (console_ioctl.h says that any higher-
+# order bit than LED_CAP will do).
+LED_RESET = 8
+
+# Number of VTs on which to set the keyboard LEDs.
+MAX_VTS = 8
+
+# Set of FDs of all TTYs on which to set LEDs.  Lazily initialized by SetLeds.
+_tty_fds = None
+_tty_fds_lock = threading.Lock()
+
+def SetLeds(state):
+    '''
+    Sets the current LEDs on VTs [0,MAX_VTS) to the given state.  Errors
+    are ignored.
+
+    (We set the LED on all VTs because /dev/console may not work reliably under
+    the combination of X and autotest.)
+
+    Args:
+        pattern: A bitwise OR of zero or more of LED_SCR, LED_NUM, and LED_CAP.
+    '''
+    global _tty_fds
+    with _tty_fds_lock:
+        if _tty_fds is None:
+            _tty_fds = []
+            for tty in xrange(MAX_VTS):
+                dev = '/dev/tty%d' % tty
+                try:
+                    _tty_fds.append(os.open(dev, os.O_RDWR))
+                except:
+                    logging.exception('Unable to open %s' % dev)
+
+    for fd in _tty_fds:
+        try:
+            fcntl.ioctl(fd, KDSETLED, state)
+        except:
+            pass
+
+
+class Blinker(object):
+    '''
+    Blinks LEDs asynchronously according to a particular pattern.
+
+    Start() and Stop() are not thread-safe and must be invoked from the same
+    thread.
+
+    This can also be used as a context manager:
+
+        with leds.Blinker(...):
+            ...do something that will take a while...
+    '''
+    thread = None
+
+    def __init__(self, pattern):
+        '''
+        Constructs the blinker (but does not start it).
+
+        Args:
+            pattern: A list of tuples.  Each element is (state, duration),
+                where state contains the LEDs that should be lit (a bitwise
+                OR of LED_SCR, LED_NUM, and/or LED_CAP).  For example,
+
+                    ((LED_SCR|LED_NUM|LED_CAP, .2),
+                     (0, .05))
+
+                would turn all LEDs on for .2 s, then all off for 0.05 s,
+                ad infinitum.
+        '''
+        self.pattern = pattern
+        self.done = threading.Event()
+
+    def Start(self):
+        '''
+        Starts blinking in a separate thread until Stop is called.
+
+        May only be invoked once.
+        '''
+        assert not self.thread
+        self.thread = threading.Thread(target=self._Run)
+        self.thread.start()
+
+    def Stop(self):
+        '''
+        Stops blinking.
+        '''
+        self.done.set()
+        if self.thread:
+            self.thread.join()
+            self.thread = None
+
+    def __enter__(self):
+        self.Start()
+
+    def __exit__(self, type, value, traceback):
+        self.Stop()
+
+    def _Run(self):
+        while True:  # Repeat pattern forever
+            for state, duration in self.pattern:
+                SetLeds(state)
+                self.done.wait(duration)
+                if self.done.is_set():
+                    SetLeds(LED_RESET)
+                    return
+
+
+if __name__ == '__main__':
+    '''
+    Blinks the pattern in sys.argv[1] if peresent, or the famous theme from
+    William Tell otherwise.
+    '''
+    if len(sys.argv) > 1:
+        blinker = Blinker(eval(sys.argv[1]))
+    else:
+        DURATION_SCALE = .125
+
+        def Blip(state, duration=1):
+            return [(state, duration * .6 * DURATION_SCALE),
+                    (0, duration * .4 * DURATION_SCALE)]
+
+        blinker = Blinker(
+            2*(2*Blip(LED_NUM) + Blip(LED_NUM, 2)) + 2*Blip(LED_NUM) +
+            Blip(LED_CAP, 2) + Blip(LED_SCR, 2) + Blip(LED_CAP|LED_SCR, 2) +
+            2*Blip(LED_NUM) + Blip(LED_NUM, 2) + 2*Blip(LED_NUM) +
+            Blip(LED_CAP, 2) + 2*Blip(LED_SCR) + Blip(LED_CAP, 2) +
+            Blip(LED_NUM, 2) + Blip(LED_CAP|LED_NUM, 2)
+            )
+
+    with blinker:
+        # Wait for newline, and then quit gracefully
+        sys.stdin.readline()
diff --git a/py/test/line_item_check.py b/py/test/line_item_check.py
new file mode 100644
index 0000000..334dd11
--- /dev/null
+++ b/py/test/line_item_check.py
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2012 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.
+
+# DESCRIPTION :
+#
+# There are test situations that we want to execute certain commands and
+# check whether the commands run correctly by human judgement. This is a
+# base class provides a generic framework to itemize tests in this category.
+
+import gtk
+
+from autotest_lib.client.bin import test
+from autotest_lib.client.cros import factory
+from autotest_lib.client.common_lib import error
+from autotest_lib.client.common_lib import utils
+from autotest_lib.client.cros.factory import task
+from autotest_lib.client.cros.factory import ui as ful
+
+
+def _make_decision_widget(message, key_action_mapping):
+    '''Returns a widget that display the message and bind proper functions.
+
+    @param message: Message to display on the widget.
+    @param key_release_callback:
+        A dict of tuples indicates functions and keys
+        in the format {gtk_keyval: (function, function_parameters)}
+    @return A widget binds with proper functions.
+    '''
+    widget = gtk.VBox()
+    widget.add(ful.make_label(message))
+    def key_release_callback(_, event):
+        if event.keyval in key_action_mapping:
+            callback, callback_parameters = key_action_mapping[event.keyval]
+            callback(*callback_parameters)
+            return True
+
+    widget.key_callback = key_release_callback
+    return widget
+
+
+class FactoryLineItemCheckBase(test.test):
+    version = 1
+
+    def run_once(self):
+        raise NotImplementedError
+
+    def _next_item(self):
+        self._item = self._item + 1
+        if self._item < len(self._items):
+            # Update the UI.
+            widget, cmd_line = self._items[self._item]
+            self._switch_widget(widget)
+            # Execute command after UI is updated.
+            if cmd_line:
+                task.schedule(self._run_cmd, cmd_line)
+        else:
+            # No more item.
+            gtk.main_quit()
+
+    def _switch_widget(self, widget_to_display):
+        if widget_to_display is not self.last_widget:
+            if self.last_widget:
+                self.last_widget.hide()
+                self.test_widget.remove(self.last_widget)
+            self.last_widget = widget_to_display
+            self.test_widget.add(widget_to_display)
+            self.test_widget.show_all()
+        else:
+            return
+
+    def _register_callbacks(self, window):
+        def key_press_callback(widget, event):
+            if hasattr(self.last_widget, 'key_callback'):
+                return self.last_widget.key_callback(widget, event)
+            return False
+        window.connect('key-press-event', key_press_callback)
+        window.add_events(gtk.gdk.KEY_PRESS_MASK)
+
+    def _run_cmd(self, cmd):
+        factory.log('Running command [%s]' % cmd)
+        ret = utils.system_output(cmd)
+        factory.log('Command returns [%s]' % ret)
+
+    def _fail_test(self, cmd):
+        raise error.TestFail('Failed with command [%s]' % cmd)
+
+    def _check_line_items(self, item_tuples):
+        factory.log('%s run_once' % self.__class__)
+        # Initialize variables.
+        self.last_widget = None
+        self.item_tuples = item_tuples
+
+        # Line item in (widget, command) format.
+        self._items = []
+
+        # Set up the widgets.
+        # There are two types of widgets, one gives instructions without
+        # judgement, the other decides whether the result matches expectations.
+        self.widgets = []
+        for idx, _tuple in enumerate(self.item_tuples):
+            judge, cmd_line, prompt_message = _tuple
+            if judge:
+                # Widget involves human judgement.
+                key_action_mapping = {
+                    gtk.keysyms.Return: (self._next_item, []),
+                    gtk.keysyms.Tab: (self._fail_test, [cmd_line])}
+                self.widgets.append(_make_decision_widget(
+                    prompt_message + ful.USER_PASS_FAIL_SELECT_STR,
+                    key_action_mapping))
+                self._items.append(
+                    (self.widgets[idx], cmd_line))
+            else:
+                key_action_mapping = {
+                    gtk.keysyms.space: (self._next_item, [])}
+                self.widgets.append(_make_decision_widget(
+                    prompt_message,
+                    key_action_mapping))
+                self._items.append((self.widgets[idx], None))
+
+            factory.log('Item %d: %s' % (idx, cmd_line))
+
+        self.test_widget = gtk.VBox()
+        self._item = -1
+        self._next_item()
+
+        ful.run_test_widget(
+                self.job,
+                self.test_widget,
+                window_registration_callback=self._register_callbacks)
diff --git a/py/test/media_util.py b/py/test/media_util.py
new file mode 100644
index 0000000..a2b4279
--- /dev/null
+++ b/py/test/media_util.py
@@ -0,0 +1,137 @@
+# Copyright (c) 2012 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 commands
+import logging
+import os
+import pyudev
+import pyudev.glib
+
+from autotest_lib.client.common_lib.autotemp import tempdir
+
+# udev constants
+_UDEV_ACTION_INSERT = 'add'
+_UDEV_ACTION_REMOVE = 'remove'
+
+
+class MediaMonitor():
+    """A wrapper to monitor media events.
+
+    This class offers an easy way to monitor the insertion and removal
+    activities of media devices.
+
+    Usage example:
+        monitor = MediaMonitor()
+        monitor.start(on_insert=on_insert, on_remove=on_remove)
+        monitor.stop()
+    """
+    def __init__(self):
+        self._monitoring = False
+
+    def udev_event_callback(self, _, action, device):
+        if action == _UDEV_ACTION_INSERT:
+            logging.info("Device inserted %s" % device.device_node)
+            self.on_insert(device.device_node)
+        elif action == _UDEV_ACTION_REMOVE:
+            logging.info('Device removed : %s' % device.device_node)
+            self.on_remove(device.device_node)
+
+    def start(self, on_insert, on_remove):
+        if self._monitoring:
+            raise Exception("Multiple start() call is not allowed")
+        self.on_insert = on_insert
+        self.on_remove = on_remove
+        # Setup the media monitor,
+        context = pyudev.Context()
+        self.monitor = pyudev.Monitor.from_netlink(context)
+        self.monitor.filter_by(subsystem='block', device_type='disk')
+        observer = pyudev.glib.GUDevMonitorObserver(self.monitor)
+        observer.connect('device-event', self.udev_event_callback)
+        self._monitoring = True
+        self._observer = observer
+        self.monitor.start()
+        logging.info("Monitoring media actitivity")
+
+    def stop(self):
+        # TODO(itspeter) : Add stop functionality as soon as
+        #                  pyudev.Monitor support it.
+        self._monitoring = False
+
+
+class MountedMedia():
+    """A context manager to automatically mount and unmount specified device.
+
+    Usage example:
+        To mount the third partition of /dev/sda.
+
+        with MountedMedia('/dev/sda', 3) as media_path:
+            print("Mounted at %s." % media_path)
+    """
+
+    def __init__(self, dev_path, partition=None):
+        """Constructs a context manager to automatically mount/umount.
+
+        Args:
+            dev_path: The absolute path to the device.
+            partition: A optional number indicated which partition of the device
+                       should be mounted. If None is given, the dev_path will be
+                       the mounted partition.
+        Returns:
+            A MountedMedia instance with initialized proper path.
+
+        Example:
+            with MountedMedia('/dev/sdb', 1) as path:
+                with open(os.path.join(path, 'test'), 'w') as f:
+                    f.write('test')
+        """
+        self._mounted = False
+        if partition is None:
+            self._dev_path = dev_path
+            return
+
+        if dev_path[-1].isdigit():
+            # Devices enumerated in numbers (ex, mmcblk0).
+            self._dev_path = '%sp%d' % (dev_path, partition)
+        else:
+            # Devices enumerated in alphabets (ex, sda)
+            self._dev_path = '%s%d' % (dev_path, partition)
+
+        # For devices not using partition table (floppy mode),
+        # allow using whole device as first partition.
+        if (not os.path.exists(self._dev_path)) and (partition == 1):
+            logging.info('Using device without partition table - %s', dev_path)
+            self._dev_path = dev_path
+
+    def __enter__(self):
+        self._mount_media()
+        return self.mount_dir.name
+
+    def __exit__(self, type, value, traceback):
+        if self._mounted:
+            self._umount_media()
+
+    def _mount_media(self):
+        """Mount a partition of media at temporary directory.
+
+        Exceptions are throwed if anything goes wrong.
+        """
+        # Create an temporary mount directory to mount.
+        self.mount_dir = tempdir(unique_id='MountedMedia')
+        logging.info("Media mount directory created: %s" % self.mount_dir.name)
+        exit_code, output = commands.getstatusoutput(
+                'mount %s %s' % (self._dev_path, self.mount_dir.name))
+        if exit_code != 0:
+            self.mount_dir.clean()
+            raise Exception("Failed to mount. Message-%s" % output)
+        self._mounted = True
+
+    def _umount_media(self):
+        """Umounts the partition of the media."""
+        # Umount media and delete the temporary directory.
+        exit_code, output = commands.getstatusoutput(
+                'umount %s' % self.mount_dir.name)
+        if exit_code != 0:
+            raise Exception("Failed to umount. Message-%s" % output)
+        self.mount_dir.clean()
+        self._mounted = False
diff --git a/py/test/media_util_unittest.py b/py/test/media_util_unittest.py
new file mode 100644
index 0000000..4702d83
--- /dev/null
+++ b/py/test/media_util_unittest.py
@@ -0,0 +1,182 @@
+# Copyright (c) 2012 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 factory_common
+
+import commands
+import glib
+import gtk
+import logging
+import os
+import pyudev
+import unittest
+
+from autotest_lib.client.common_lib.autotemp import tempfile
+from autotest_lib.client.cros.factory.media_util import MediaMonitor
+from autotest_lib.client.cros.factory.media_util import MountedMedia
+
+# udev constants
+_UDEV_ACTION_INSERT = 'add'
+_UDEV_ACTION_REMOVE = 'remove'
+
+_WRITING_TEST_FILENAME = 'media_util_unittest.test'
+_WRITING_TEST_STR = 'Unittest writing test...'
+_VIRTUAL_PATITION_NUMBER = 3
+
+
+class TestMountedMedia(unittest.TestCase):
+    def setUp(self):
+        """Creates a temp file to mock as a media device."""
+        self._virtual_device = tempfile(unique_id='media_util_unitttest')
+        exit_code, ret = commands.getstatusoutput(
+            'truncate -s 1048576 %s && mkfs -F -t ext3 %s' %
+            (self._virtual_device.name, self._virtual_device.name))
+        self.assertEqual(0, exit_code)
+
+        exit_code, ret = commands.getstatusoutput('losetup --show -f %s' %
+            self._virtual_device.name)
+        self._free_loop_device = ret
+        self.assertEqual(0, exit_code)
+
+    def tearDown(self):
+        exit_code, ret = commands.getstatusoutput(
+            'losetup -d %s' % self._free_loop_device)
+        self.assertEqual(0, exit_code)
+        self._virtual_device.clean()
+
+    def testFailToMount(self):
+        """Tests the MountedMedia throws exceptions when it fails."""
+        def with_wrapper():
+            with MountedMedia('/dev/device_not_exist') as path:
+                pass
+        self.assertRaises(Exception, with_wrapper)
+
+    def testNormalMount(self):
+        """Tests mounting partition."""
+        with MountedMedia(self._free_loop_device) as path:
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'w') as f:
+                f.write(_WRITING_TEST_STR)
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'r') as f:
+                self.assertEqual(_WRITING_TEST_STR, f.readline())
+
+    def testPartitionMountSDA(self):
+        """Tests mounting partition.
+
+           This tests mounting partition with devices enumerated
+           in alphabets (ex, sda).
+        """
+        virtual_partition = tempfile(unique_id='virtual_partition',
+                                     suffix='sdc%d' %
+                                     _VIRTUAL_PATITION_NUMBER)
+        exit_code, ret = commands.getstatusoutput(
+            'ln -s -f %s %s' %
+            (self._free_loop_device, virtual_partition.name))
+        self.assertEqual(0, exit_code)
+
+        with MountedMedia(virtual_partition.name[:-1],
+                          _VIRTUAL_PATITION_NUMBER) as path:
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'w') as f:
+                f.write(_WRITING_TEST_STR)
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'r') as f:
+                self.assertEqual(_WRITING_TEST_STR, f.readline())
+        virtual_partition.clean()
+
+    def testPartitionMountMMCBLK0(self):
+        """Tests mounting partition.
+
+           This tests mounting partition with devices enumerated
+           in alphabets (ex, mmcblk0).
+        """
+        virtual_partition = tempfile(unique_id='virtual_partition',
+                                     suffix='mmcblk0p%d' %
+                                     _VIRTUAL_PATITION_NUMBER)
+        exit_code, ret = commands.getstatusoutput(
+            'ln -s -f %s %s' %
+            (self._free_loop_device, virtual_partition.name))
+        self.assertEqual(0, exit_code)
+
+        with MountedMedia(virtual_partition.name[:-2],
+                          _VIRTUAL_PATITION_NUMBER) as path:
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'w') as f:
+                f.write(_WRITING_TEST_STR)
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'r') as f:
+                self.assertEqual(_WRITING_TEST_STR, f.readline())
+        virtual_partition.clean()
+
+    def testPartitionMountFloppy(self):
+        """Tests mounting a device without partition table."""
+        with MountedMedia(self._free_loop_device, 1) as path:
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'w') as f:
+                f.write(_WRITING_TEST_STR)
+            with open(os.path.join(path, _WRITING_TEST_FILENAME), 'r') as f:
+                self.assertEqual(_WRITING_TEST_STR, f.readline())
+
+
+class TestMediaMonitor(unittest.TestCase):
+    def setUp(self):
+        """Creates a temp file to mock as a media device."""
+        self._virtual_device = tempfile(unique_id='media_util_unitttest')
+        exit_code, ret = commands.getstatusoutput(
+            'truncate -s 1048576 %s' % self._virtual_device.name)
+        self.assertEqual(0, exit_code)
+
+        exit_code, ret = commands.getstatusoutput('losetup --show -f %s' %
+            self._virtual_device.name)
+        self._free_loop_device = ret
+        self.assertEqual(0, exit_code)
+
+    def tearDown(self):
+        exit_code, ret = commands.getstatusoutput(
+            'losetup -d %s' % self._free_loop_device)
+        self.assertEqual(0, exit_code)
+        self._virtual_device.clean()
+
+    def testMediaMonitor(self):
+        def on_insert(dev_path):
+            self.assertEqual(self._free_loop_device, dev_path)
+            self._media_inserted = True
+            gtk.main_quit()
+        def on_remove(dev_path):
+            self.assertEqual(self._free_loop_device, dev_path)
+            self._media_removed = True
+            gtk.main_quit()
+        def one_time_timer_mock_insert():
+            monitor._observer.emit('device-event',
+                                   _UDEV_ACTION_INSERT,
+                                   self._mock_device)
+            return False
+        def one_time_timer_mock_remove():
+            monitor._observer.emit('device-event',
+                                   _UDEV_ACTION_REMOVE,
+                                   self._mock_device)
+            return False
+
+        self._media_inserted = False
+        self._media_removed = False
+        self._context = pyudev.Context()
+        self._mock_device = pyudev.Device.from_name(
+            self._context, 'block',
+            os.path.basename(self._free_loop_device))
+
+        # Start the monitor.
+        TIMEOUT_SECOND = 1
+        monitor = MediaMonitor()
+        monitor.start(on_insert=on_insert, on_remove=on_remove)
+        # Simulating the insertion of a valid media device.
+        timer_tag = glib.timeout_add_seconds(TIMEOUT_SECOND,
+                                             one_time_timer_mock_insert)
+        gtk.main()
+        # Simulating the removal of a valid media device.
+        glib.source_remove(timer_tag)
+        timer_tag = glib.timeout_add_seconds(TIMEOUT_SECOND,
+                                             one_time_timer_mock_remove)
+        gtk.main()
+
+        monitor.stop()
+        self.assertEqual(True, self._media_inserted)
+        self.assertEqual(True, self._media_removed)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/py/test/pytests/__init__.py b/py/test/pytests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/py/test/pytests/__init__.py
diff --git a/py/test/pytests/execpython.py b/py/test/pytests/execpython.py
new file mode 100644
index 0000000..f98df72
--- /dev/null
+++ b/py/test/pytests/execpython.py
@@ -0,0 +1,15 @@
+
+import logging
+import unittest
+
+class ExecPythonTest(unittest.TestCase):
+    '''A simple test that just executes a Python script.
+
+    Args:
+        script: The Python code to execute.
+    '''
+    def runTest(self):
+        script = self.test_info.args['script']
+        logging.info("Executing Python script: '''%s'''", script)
+        exec script in {'test_info': self.test_info}, {}
+        logging.info("Script succeeded")
diff --git a/py/test/shopfloor.py b/py/test/shopfloor.py
new file mode 100644
index 0000000..c8c9fb7
--- /dev/null
+++ b/py/test/shopfloor.py
@@ -0,0 +1,242 @@
+# Copyright (c) 2010 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.
+
+
+"""Wrapper for Factory Shop Floor.
+
+This module provides a simple interface for all factory tests to access ChromeOS
+factory shop floor system.
+
+The common flow is:
+    - Sets shop floor server URL by shopfloor.set_server_url(url).
+    - Tries shopfllor.check_serial_number(sn) until a valid value is found.
+    - Calls shopfloor.set_enabled(True) to notify other tests.
+    - Gets data by shopfloor.get_*() (ex, get_hwid()).
+    - Uploads reports by shopfloor.upload_report(blob, name).
+    - Finalize by shopfloor.finalize()
+
+For the protocol details, check:
+ src/platform/factory-utils/factory_setup/shopfloor_server.
+"""
+
+import logging
+import os
+import urlparse
+import xmlrpclib
+from xmlrpclib import Binary, Fault
+
+import factory_common
+from autotest_lib.client.cros import factory
+
+
+# Name of the factory shared data key that maps to session info.
+KEY_SHOPFLOOR_SESSION = 'shopfloor.session'
+
+# Session data will be serialized, so we're not using class/namedtuple. The
+# session is a simple dictionary with following keys:
+SESSION_SERIAL_NUMBER = 'serial_number'
+SESSION_SERVER_URL = 'server_url'
+SESSION_ENABLED = 'enabled'
+
+API_GET_HWID = 'GetHWID'
+API_GET_VPD = 'GetVPD'
+
+# Default port number from shopfloor_server.py.
+_DEFAULT_SERVER_PORT = 8082
+
+# Environment variable containing the shopfloor server URL (for
+# testing).  Setting this overrides the shopfloor server URL and
+# causes the shopfloor server to be considered enabled.
+SHOPFLOOR_SERVER_ENV_VAR_NAME = 'CROS_SHOPFLOOR_SERVER_URL'
+
+# ----------------------------------------------------------------------------
+# Exception Types
+
+class ServerFault(Exception):
+    pass
+
+
+def _server_api(call):
+    """Decorator of calls to remote server.
+
+    Converts xmlrpclib.Fault generated during remote procedural call to better
+    and simplified form (shopfloor.ServerFault).
+    """
+    def wrapped_call(*args, **kargs):
+        try:
+            return call(*args, **kargs)
+        except xmlrpclib.Fault as e:
+            logging.exception('Shopfloor server:')
+            raise ServerFault(e.faultString.partition(':')[2])
+    wrapped_call.__name__ = call.__name__
+    return wrapped_call
+
+# ----------------------------------------------------------------------------
+# Utility Functions
+
+def _fetch_current_session():
+    """Gets current shop floor session from factory states shared data.
+
+    If no session is stored yet, create a new default session.
+    """
+    if factory.has_shared_data(KEY_SHOPFLOOR_SESSION):
+        session = factory.get_shared_data(KEY_SHOPFLOOR_SESSION)
+    else:
+        session = {SESSION_SERIAL_NUMBER: None,
+                   SESSION_SERVER_URL: None,
+                   SESSION_ENABLED: False}
+        factory.set_shared_data(KEY_SHOPFLOOR_SESSION, session)
+    return session
+
+
+def _set_session(key, value):
+    """Sets shop floor session value to factory states shared data."""
+    # Currently there's no locking/transaction mechanism in factory shared_data,
+    # so there may be race-condition issue if multiple background tests try to
+    # set shop floor session data at the same time.  However since shop floor
+    # session should be singularily configured in the very beginning, let's fix
+    # this only if that really becomes an issue.
+    session = _fetch_current_session()
+    assert key in session, "Unknown session key: %s" % key
+    session[key] = value
+    factory.set_shared_data(KEY_SHOPFLOOR_SESSION, session)
+
+
+def _get_session(key):
+    """Gets shop floor session value from factory states shared data."""
+    session = _fetch_current_session()
+    assert key in session, "Unknown session key: %s" % key
+    return session[key]
+
+
+def reset():
+    """Resets session data from factory states shared data."""
+    if factory.has_shared_data(KEY_SHOPFLOOR_SESSION):
+        factory.del_shared_data(KEY_SHOPFLOOR_SESSION)
+
+
+def is_enabled():
+    """Checks if current factory is configured to use shop floor system."""
+    return (bool(os.environ.get(SHOPFLOOR_SERVER_ENV_VAR_NAME)) or
+            _get_session(SESSION_ENABLED))
+
+
+def set_enabled(enabled):
+    """Enable/disable using shop floor in current factory flow."""
+    _set_session(SESSION_ENABLED, enabled)
+
+
+def set_server_url(url):
+    """Sets default shop floor server URL for further calls."""
+    _set_session(SESSION_SERVER_URL, url)
+
+
+def get_server_url():
+    """Gets last configured shop floor server URL."""
+    return (os.environ.get(SHOPFLOOR_SERVER_ENV_VAR_NAME) or
+            _get_session(SESSION_SERVER_URL))
+
+
+def detect_default_server_url():
+    """Tries to find a default shop floor server URL.
+
+       Searches from lsb-* files and deriving from mini-omaha server location.
+    """
+    lsb_values = factory.get_lsb_data()
+    # FACTORY_OMAHA_URL is written by factory_install/factory_install.sh
+    omaha_url = lsb_values.get('FACTORY_OMAHA_URL', None)
+    if omaha_url:
+        omaha = urlparse.urlsplit(omaha_url)
+        netloc = '%s:%s' % (omaha.netloc.split(':')[0], _DEFAULT_SERVER_PORT)
+        return urlparse.urlunsplit((omaha.scheme, netloc, '/', '', ''))
+    return None
+
+
+def get_instance(url=None, detect=False):
+    """Gets an instance (for client side) to access the shop floor server.
+
+    @param url: URL of the shop floor server. If None, use the value in
+            factory shared data.
+    @param detect: If True, attempt to detect the server URL if none is
+        specified.
+    @return An object with all public functions from shopfloor.ShopFloorBase.
+    """
+    if not url:
+        url = get_server_url()
+    if not url and detect:
+        url = detect_default_server_url()
+    if not url:
+        raise Exception("Shop floor server URL is NOT configured.")
+    return xmlrpclib.ServerProxy(url, allow_none=True, verbose=False)
+
+
+@_server_api
+def check_server_status(instance=None):
+    """Checks if the given instance is successfully connected.
+
+    @param instance: Instance object created get_instance, or None to create a
+            new instance.
+    @return True for success, otherwise raise exception.
+    """
+    try:
+        if instance is not None:
+            instance = get_instance()
+        instance.Ping()
+    except:
+        raise
+    return True
+
+
+# ----------------------------------------------------------------------------
+# Functions to access shop floor server by APIs defined by ChromeOS factory shop
+# floor system (see src/platform/factory-utils/factory_setup/shopfloor/*).
+
+
+@_server_api
+def set_serial_number(serial_number):
+    """Sets a serial number as pinned in factory shared data."""
+    _set_session(SESSION_SERIAL_NUMBER, serial_number)
+
+
+@_server_api
+def get_serial_number():
+    """Gets current pinned serial number from factory shared data."""
+    return _get_session(SESSION_SERIAL_NUMBER)
+
+
+@_server_api
+def check_serial_number(serial_number):
+    """Checks if given serial number is valid."""
+    # Use GetHWID to check serial number.
+    return get_instance().GetHWID(serial_number)
+
+
+@_server_api
+def get_hwid():
+    """Gets HWID associated with current pinned serial number."""
+    return get_instance().GetHWID(get_serial_number())
+
+
+@_server_api
+def get_vpd():
+    """Gets VPD associated with current pinned serial number."""
+    return get_instance().GetVPD(get_serial_number())
+
+
+@_server_api
+def upload_report(blob, name=None):
+    """Uploads a report (generated by gooftool) to shop floor server.
+
+    @param blob: The report (usually a gzipped bitstream) data to upload.
+    @param name: An optional file name suggestion for server. Usually this
+        should be the default file name created by gooftool; for reports
+        generated by other tools, None allows server to choose arbitrary name.
+    """
+    get_instance().UploadReport(get_serial_number(), Binary(blob), name)
+
+
+@_server_api
+def finalize():
+    """Notifies shop floor server this DUT has finished testing."""
+    get_instance().Finalize(get_serial_number())
diff --git a/py/test/state.py b/py/test/state.py
new file mode 100644
index 0000000..47673f9
--- /dev/null
+++ b/py/test/state.py
@@ -0,0 +1,526 @@
+#!/usr/bin/env python
+# Copyright (c) 2010 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 module provides both client and server side of a XML RPC based server which
+can be used to handle factory test states (status) and shared persistent data.
+'''
+
+
+import logging
+import mimetypes
+import os
+import Queue
+import re
+import shelve
+import shutil
+import SocketServer
+import sys
+import threading
+import time
+
+from hashlib import sha1
+from uuid import uuid4
+
+import factory_common
+
+from jsonrpclib import jsonclass
+from jsonrpclib import jsonrpc
+from jsonrpclib import SimpleJSONRPCServer
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import TestState
+from autotest_lib.client.cros.factory import system
+from autotest_lib.client.cros.factory import unicode_to_string
+
+
+DEFAULT_FACTORY_STATE_PORT = 0x0FAC
+DEFAULT_FACTORY_STATE_ADDRESS = 'localhost'
+DEFAULT_FACTORY_STATE_BIND_ADDRESS = 'localhost'
+DEFAULT_FACTORY_STATE_FILE_PATH = factory.get_state_root()
+
+
+def _synchronized(f):
+    '''
+    Decorates a function to grab a lock.
+    '''
+    def wrapped(self, *args, **kw):
+        with self._lock:  # pylint: disable=W0212
+            return f(self, *args, **kw)
+    return wrapped
+
+
+def clear_state(state_file_path=None):
+    '''Clears test state (removes the state file path).
+
+    Args:
+        state_file_path: Path to state; uses the default path if None.
+    '''
+    state_file_path = state_file_path or DEFAULT_FACTORY_STATE_FILE_PATH
+    logging.warn('Clearing state file path %s' % state_file_path)
+    if os.path.exists(state_file_path):
+        shutil.rmtree(state_file_path)
+
+
+class TestHistoryItem(object):
+    def __init__(self, path, state, log, trace=None):
+        self.path = path
+        self.state = state
+        self.log = log
+        self.trace = trace
+        self.time = time.time()
+
+
+class PathResolver(object):
+    '''Resolves paths in URLs.'''
+    def __init__(self):
+        self._paths = {}
+
+    def AddPath(self, url_path, local_path):
+        '''Adds a prefix mapping:
+
+        For example,
+
+            AddPath('/foo', '/usr/local/docs')
+
+        will cause paths to resolved as follows:
+
+            /foo            -> /usr/local/docs
+            /foo/index.html -> /usr/local/docs/index.html
+
+        Args:
+            url_path: The path in the URL
+        '''
+        self._paths[url_path] = local_path
+
+    def Resolve(self, url_path):
+        '''Resolves a path mapping.
+
+        Returns None if no paths match.'
+
+        Args:
+            url_path: A path in a URL (starting with /).
+        '''
+        if not url_path.startswith('/'):
+            return None
+
+        prefix = url_path
+        while prefix != '':
+            local_prefix = self._paths.get(prefix)
+            if local_prefix:
+                return local_prefix + url_path[len(prefix):]
+            prefix, _, _ = prefix.rpartition('/')
+
+        root_prefix = self._paths.get('/')
+        if root_prefix:
+            return root_prefix + url_path
+
+
+@unicode_to_string.UnicodeToStringClass
+class FactoryState(object):
+    '''
+    The core implementation for factory state control.
+    The major provided features are:
+
+    SHARED DATA
+        You can get/set simple data into the states and share between all tests.
+        See get_shared_data(name) and set_shared_data(name, value) for more
+        information.
+
+    TEST STATUS
+        To track the execution status of factory auto tests, you can use
+        get_test_state, get_test_states methods, and update_test_state
+        methods.
+
+    All arguments may be provided either as strings, or as Unicode strings in
+    which case they are converted to strings using UTF-8.  All returned values
+    are strings (not Unicode).
+
+    This object is thread-safe.
+
+    See help(FactoryState.[methodname]) for more information.
+
+    Properties:
+        _generated_files: Map from UUID to paths on disk.  These are
+            not persisted on disk (though they could be if necessary).
+        _generated_data: Map from UUID to (mime_type, data) pairs for
+            transient objects to serve.
+        _generated_data_expiration: Priority queue of expiration times
+            for objects in _generated_data.
+    '''
+
+    def __init__(self, state_file_path=None):
+        '''
+        Initializes the state server.
+
+        Parameters:
+            state_file_path:    External file to store the state information.
+        '''
+        state_file_path = state_file_path or DEFAULT_FACTORY_STATE_FILE_PATH
+        if not os.path.exists(state_file_path):
+            os.makedirs(state_file_path)
+        self._tests_shelf = shelve.open(state_file_path + '/tests')
+        self._data_shelf = shelve.open(state_file_path + '/data')
+        self._test_history_shelf = shelve.open(state_file_path +
+                                               '/test_history')
+        self._lock = threading.RLock()
+        self.test_list_struct = None
+
+        self._generated_files = {}
+        self._generated_data = {}
+        self._generated_data_expiration = Queue.PriorityQueue()
+        self._resolver = PathResolver()
+
+        if TestState not in jsonclass.supported_types:
+            jsonclass.supported_types.append(TestState)
+
+    @_synchronized
+    def close(self):
+        '''
+        Shuts down the state instance.
+        '''
+        for shelf in [self._tests_shelf,
+                      self._data_shelf,
+                      self._test_history_shelf]:
+            try:
+                shelf.close()
+            except:
+                logging.exception('Unable to close shelf')
+
+    @_synchronized
+    def update_test_state(self, path, **kw):
+        '''
+        Updates the state of a test.
+
+        See TestState.update for the allowable keyword arguments.
+
+        @param path: The path to the test (see FactoryTest for a description
+            of test paths).
+        @param kw: See TestState.update for allowable arguments (e.g.,
+            status and increment_count).
+
+        @return: A tuple containing the new state, and a boolean indicating
+            whether the state was just changed.
+        '''
+        state = self._tests_shelf.get(path)
+        old_state_repr = repr(state)
+        changed = False
+
+        if not state:
+            changed = True
+            state = TestState()
+
+        changed = changed | state.update(**kw)  # Don't short-circuit
+
+        if changed:
+            logging.debug('Updating test state for %s: %s -> %s',
+                          path, old_state_repr, state)
+            self._tests_shelf[path] = state
+            self._tests_shelf.sync()
+
+        return state, changed
+
+    @_synchronized
+    def get_test_state(self, path):
+        '''
+        Returns the state of a test.
+        '''
+        return self._tests_shelf[path]
+
+    @_synchronized
+    def get_test_paths(self):
+        '''
+        Returns a list of all tests' paths.
+        '''
+        return self._tests_shelf.keys()
+
+    @_synchronized
+    def get_test_states(self):
+        '''
+        Returns a map of each test's path to its state.
+        '''
+        return dict(self._tests_shelf)
+
+    def get_test_list(self):
+        '''
+        Returns the test list.
+        '''
+        return self.test_list.to_struct()
+
+    @_synchronized
+    def set_shared_data(self, *key_value_pairs):
+        '''
+        Sets shared data items.
+
+        Args:
+            key_value_pairs: A series of alternating keys and values
+                (k1, v1, k2, v2...).  In the simple case this can just
+                be a single key and value.
+        '''
+        assert len(key_value_pairs) % 2 == 0, repr(key_value_pairs)
+        for i in range(0, len(key_value_pairs), 2):
+            self._data_shelf[key_value_pairs[i]] = key_value_pairs[i + 1]
+        self._data_shelf.sync()
+
+    @_synchronized
+    def get_shared_data(self, key, optional=False):
+        '''
+        Retrieves a shared data item.
+
+        Args:
+            key: The key whose value to retrieve.
+            optional: True to return None if not found; False to raise
+                a KeyError.
+        '''
+        if optional:
+            return self._data_shelf.get(key)
+        else:
+            return self._data_shelf[key]
+
+    @_synchronized
+    def has_shared_data(self, key):
+        '''
+        Returns if a shared data item exists.
+        '''
+        return key in self._data_shelf
+
+    @_synchronized
+    def del_shared_data(self, key, optional=False):
+        '''
+        Deletes a shared data item.
+
+        Args:
+            key: The key whose value to retrieve.
+            optional: False to raise a KeyError if not found.
+        '''
+        try:
+            del self._data_shelf[key]
+        except KeyError:
+            if not optional:
+                raise
+
+    @_synchronized
+    def add_test_history(self, history_item):
+        path = history_item.path
+        assert path
+
+        length_key = path + '[length]'
+        num_entries = self._test_history_shelf.get(length_key, 0)
+        self._test_history_shelf[path + '[%d]' % num_entries] = history_item
+        self._test_history_shelf[length_key] = num_entries + 1
+
+    @_synchronized
+    def get_test_history(self, paths):
+        if type(paths) != list:
+            paths = [paths]
+        ret = []
+
+        for path in paths:
+            i = 0
+            while True:
+                value = self._test_history_shelf.get(path + '[%d]' % i)
+
+                i += 1
+                if not value:
+                    break
+                ret.append(value)
+
+        ret.sort(key=lambda item: item.time)
+
+        return ret
+
+    @_synchronized
+    def url_for_file(self, path):
+        '''Returns a URL that can be used to serve a local file.
+
+        Args:
+          path: path to the local file
+
+        Returns:
+          url: A (possibly relative) URL that refers to the file
+        '''
+        uuid = str(uuid4())
+        uri_path = '/generated-files/%s/%s' % (uuid, os.path.basename(path))
+        self._generated_files[uuid] = path
+        return uri_path
+
+    @_synchronized
+    def url_for_data(self, mime_type, data, expiration_secs=None):
+        '''Returns a URL that can be used to serve a static collection
+        of bytes.
+
+        Args:
+          mime_type: MIME type for the data
+          data: Data to serve
+          expiration_secs: If not None, the number of seconds in which
+            the data will expire.
+        '''
+        uuid = str(uuid4())
+        self._generated_data[uuid] = mime_type, data
+        if expiration_secs:
+            now = time.time()
+            self._generated_data_expiration.put(
+                (now + expiration_secs, uuid))
+
+            # Reap old items.
+            while True:
+                try:
+                    item = self._generated_data_expiration.get_nowait()
+                except Queue.Empty:
+                    break
+
+                if item[0] < now:
+                    del self._generated_data[item[1]]
+                else:
+                    # Not expired yet; put it back and we're done
+                    self._generated_data_expiration.put(item)
+                    break
+        uri_path = '/generated-data/%s' % uuid
+        return uri_path
+
+    @_synchronized
+    def register_path(self, url_path, local_path):
+        self._resolver.AddPath(url_path, local_path)
+
+    def get_system_status(self):
+        '''Returns system status information.
+
+        This may include system load, battery status, etc.  See
+        system.SystemStatus().
+        '''
+        return system.SystemStatus().__dict__
+
+
+def get_instance(address=DEFAULT_FACTORY_STATE_ADDRESS,
+                 port=DEFAULT_FACTORY_STATE_PORT):
+    '''
+    Gets an instance (for client side) to access the state server.
+
+    @param address: Address of the server to be connected.
+    @param port: Port of the server to be connected.
+    @return An object with all public functions from FactoryState.
+        See help(FactoryState) for more information.
+    '''
+    return jsonrpc.ServerProxy('http://%s:%d' % (address, port),
+                               verbose=False)
+
+
+class MyJSONRPCRequestHandler(SimpleJSONRPCServer.SimpleJSONRPCRequestHandler):
+    def do_GET(self):
+        logging.debug('HTTP request for path %s', self.path)
+
+        handler = self.server.handlers.get(self.path)
+        if handler:
+            return handler(self)
+
+        match = re.match('^/generated-data/([-0-9a-f]+)$', self.path)
+        if match:
+            generated_data = self.server._generated_data.get(match.group(1))
+            if not generated_data:
+                logging.warn('Unknown or expired generated data %s',
+                             match.group(1))
+                self.send_response(404)
+                return
+
+            mime_type, data = generated_data
+
+            self.send_response(200)
+            self.send_header('Content-Type', mime_type)
+            self.send_header('Content-Length', len(data))
+            self.end_headers()
+            self.wfile.write(data)
+
+        if self.path.endswith('/'):
+            self.path += 'index.html'
+
+        if ".." in self.path.split("/"):
+            logging.warn("Invalid path")
+            self.send_response(404)
+            return
+
+        mime_type = mimetypes.guess_type(self.path)
+        if not mime_type:
+            logging.warn("Unable to guess MIME type")
+            self.send_response(404)
+            return
+
+        local_path = None
+        match = re.match('^/generated-files/([-0-9a-f]+)/', self.path)
+        if match:
+            local_path = self.server._generated_files.get(match.group(1))
+            if not local_path:
+                logging.warn('Unknown generated file %s in path %s',
+                             match.group(1), self.path)
+                self.send_response(404)
+                return
+
+        local_path = self.server._resolver.Resolve(self.path)
+        if not local_path or not os.path.exists(local_path):
+            logging.warn("File not found: %s", (local_path or self.path))
+            self.send_response(404)
+            return
+
+        self.send_response(200)
+        self.send_header("Content-Type", mime_type[0])
+        self.send_header("Content-Length", os.path.getsize(local_path))
+        self.end_headers()
+        with open(local_path) as f:
+            shutil.copyfileobj(f, self.wfile)
+
+
+class ThreadedJSONRPCServer(SocketServer.ThreadingMixIn,
+                            SimpleJSONRPCServer.SimpleJSONRPCServer):
+    '''The JSON/RPC server.
+
+    Properties:
+        handlers: A map from URLs to callbacks handling them.  (The callback
+            takes a single argument: the request to handle.)
+    '''
+    def __init__(self, *args, **kwargs):
+        SimpleJSONRPCServer.SimpleJSONRPCServer.__init__(self, *args, **kwargs)
+        self.handlers = {}
+
+    def add_handler(self, url, callback):
+        self.handlers[url] = callback
+
+
+def create_server(state_file_path=None, bind_address=None, port=None):
+    '''
+    Creates a FactoryState object and an JSON/RPC server to serve it.
+
+    @param state_file_path: The path containing the saved state.
+    @param bind_address: Address to bind to, defaulting to
+        DEFAULT_FACTORY_STATE_BIND_ADDRESS.
+    @param port: Port to bind to, defaulting to DEFAULT_FACTORY_STATE_PORT.
+    @return A tuple of the FactoryState instance and the SimpleJSONRPCServer
+        instance.
+    '''
+    # We have some icons in SVG format, but this isn't recognized in
+    # the standard Python mimetypes set.
+    mimetypes.add_type('image/svg+xml', '.svg')
+
+    if not bind_address:
+        bind_address = DEFAULT_FACTORY_STATE_BIND_ADDRESS
+    if not port:
+        port = DEFAULT_FACTORY_STATE_PORT
+    instance = FactoryState(state_file_path)
+    instance._resolver.AddPath(
+        '/',
+        os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static'))
+
+    server = ThreadedJSONRPCServer(
+        (bind_address, port),
+        requestHandler=MyJSONRPCRequestHandler,
+        logRequests=False)
+
+    # Give the server the information it needs to resolve URLs.
+    server._generated_files = instance._generated_files
+    server._generated_data = instance._generated_data
+    server._resolver = instance._resolver
+
+    server.register_introspection_functions()
+    server.register_instance(instance)
+    server.web_socket_handler = None
+    return instance, server
diff --git a/py/test/state_machine.py b/py/test/state_machine.py
new file mode 100644
index 0000000..0fde522
--- /dev/null
+++ b/py/test/state_machine.py
@@ -0,0 +1,275 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2012 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.
+
+# DESCRIPTION :
+#
+# This module provides a framework for factory test which procedure can be
+# described as a state machine, ex: factory AB panel test.
+
+
+import gtk
+import re
+
+from autotest_lib.client.bin import test
+from autotest_lib.client.cros.factory import task
+from autotest_lib.client.cros.factory import ui as ful
+
+
+_LABEL_SIZE = (300, 30)
+COLOR_MAGENTA = gtk.gdk.color_parse('magenta1')
+
+class FactoryStateMachine(test.test):
+    '''Simplified state machine for factory AB panel test.
+
+    Many factory AB panel tests shares similar procedure. This class provides
+    a framework to avoid duplicating codes. The simplified state machine is
+    designed for repeating a sequence of state without branches. Class
+    inherited this class have to implement their own run_once function and
+    have its states information ready before they call start_state_machine.
+
+    Attributes:
+        current_state: The identifier of the state.
+    '''
+
+    def __init__(self, *args, **kwargs):
+        super(FactoryStateMachine, self).__init__(*args, **kwargs)
+        self.current_state = None
+        self._states = dict()
+        self._last_widget = None
+        self._status_rows = list()
+        self._results_to_check = list()
+
+    def advance_state(self, new_state=None):
+        '''Advances state according to the states information.
+
+        A callback function will be scheduled for execution after UI is
+        updated.
+
+        Args:
+          new_state: Given a non-None value set the current state to new_state.
+        '''
+        if new_state is None:
+            self.current_state = self._states[self.current_state]['next_state']
+        else:
+            self.current_state = new_state
+        assert self.current_state in self._states, (
+            'State %s is not found' % self.current_state)
+
+        # Update the UI.
+        state_info = self._states[self.current_state]
+        self._switch_widget(state_info['widget'])
+        # Create an event to invoke function after UI is updated.
+        if state_info['callback']:
+            task.schedule(state_info['callback'],
+                          *state_info['callback_parameters'])
+
+    def _switch_widget(self, widget_to_display):
+        '''Switches current displayed widget to a new one'''
+        if widget_to_display is not self._last_widget:
+            if self._last_widget:
+                self._last_widget.hide()
+                self.test_widget.remove(self._last_widget)
+            self._last_widget = widget_to_display
+            self.test_widget.add(widget_to_display)
+            self.test_widget.show_all()
+        else:
+            return
+
+    def _key_action_mapping_callback(self, widget, event, key_action_mapping):
+        if event.keyval in key_action_mapping:
+            callback, callback_parameters = key_action_mapping[event.keyval]
+            callback(*callback_parameters)
+            return True
+
+    def make_decision_widget(self,
+                             message,
+                             key_action_mapping,
+                             fg_color=ful.LIGHT_GREEN):
+        '''Returns a widget that display the message and bind proper functions.
+
+        Args:
+          message: Message to display on the widget.
+          key_action_mapping: A dict of tuples indicates functions and keys
+              in the format {gtk_keyval: (function, function_parameters)}
+
+        Returns:
+          A widget binds with proper functions.
+        '''
+        widget = gtk.VBox()
+        widget.add(ful.make_label(message, fg=fg_color))
+        widget.key_callback = (
+            lambda w, e: self._key_action_mapping_callback(
+                w, e, key_action_mapping))
+        return widget
+
+    def make_result_widget(self,
+                           message,
+                           key_action_mapping,
+                           fg_color=ful.LIGHT_GREEN):
+        '''Returns a result widget and bind proper functions.
+
+        This function generate a result widget from _status_names and
+        _status_labels. Caller have to update the states information manually.
+
+        Args:
+          message: Message to display on the widget.
+          key_action_mapping: A dict of tuples indicates functions and keys
+              in the format {gtk_keyval: (function, function_parameters)}
+
+        Returns:
+          A widget binds with proper functions.
+        '''
+        self._status_rows.append(('result', 'Final result', True))
+
+        widget = gtk.VBox()
+        widget.add(ful.make_label(message, fg=fg_color))
+
+        self.display_dict = {}
+        for name, label, is_standard_status in self._status_rows:
+            if is_standard_status:
+                _dict, _widget = ful.make_status_row(
+                    label, ful.UNTESTED, _LABEL_SIZE)
+            else:
+                _dict, _widget = ful.make_status_row(
+                    label, '', _LABEL_SIZE, is_standard_status)
+            self.display_dict[name] = _dict
+            widget.add(_widget)
+
+        widget.key_callback = (
+            lambda w, e: self._key_action_mapping_callback(
+                w, e, key_action_mapping))
+        return widget
+
+    def make_serial_number_widget(self,
+                                  message,
+                                  on_complete,
+                                  default_validate_regex=None,
+                                  on_validate=None,
+                                  on_keypress=None,
+                                  generate_status_row=True):
+        '''Returns a serial number input widget.
+
+        Args:
+          on_complete: A callback function when completed.
+          default_validate_regex: Regular expression to validate serial number.
+          on_validate: A callback function to check the SN when ENTER pressed.
+                       If not setted, will use default_validate_regex.
+          on_keypress: A callback function when key pressed.
+          generate_status_row: Whether to generate status row.
+        '''
+        def default_validator(serial_number):
+            '''Checks SN format matches regular expression.'''
+            assert default_validate_regex, 'Regular expression is not set'
+            if re.search(default_validate_regex, serial_number):
+                return True
+            return False
+
+        if generate_status_row:
+            # Add a status row
+            self._status_rows.append(('sn', 'Serial Number', False))
+
+        if on_validate is None:
+            on_validate = default_validator
+
+        widget = ful.make_input_window(
+            prompt=message,
+            on_validate=on_validate,
+            on_keypress=on_keypress,
+            on_complete=on_complete)
+        # Make sure the entry in widget will have focus.
+        widget.connect(
+            'show',
+            lambda *x : widget.get_entry().grab_focus())
+        return widget
+
+    def register_state(self, widget,
+                       index=None,
+                       callback=None,
+                       next_state=None,
+                       callback_parameters=None):
+        '''Registers a state to transition table.
+
+        Args:
+          index: An index number for this state. If the index is not given,
+                 default to the largest index + 1.
+          widget: The widget belongs to this state.
+          callback: The callback function after UI is updated.
+          next_state: The index number of the succeeding state.
+                      If the next_state is not given, default to the
+                      index + 1.
+          callback_parameters: Extra parameters need to pass to callback.
+
+        Returns:
+          An index number of this registered state.
+        '''
+        if index is None:
+            # Auto enumerate an index.
+            if self._states:
+                index = max(self._states.keys()) + 1
+            else:
+                index = 0
+        assert isinstance(index, int), 'index is not a number'
+
+        if next_state is None:
+            next_state = index + 1
+        assert isinstance(next_state, int), 'next_state is not a number'
+
+        if callback_parameters is None:
+            callback_parameters = []
+        state = {'next_state': next_state,
+                 'widget': widget,
+                 'callback': callback,
+                 'callback_parameters': callback_parameters}
+
+        self._states[index] = state
+        return index
+
+    def _register_callbacks(self, window):
+        def key_press_callback(widget, event):
+            if self._last_widget is not None:
+                if hasattr(self._last_widget, 'key_callback'):
+                    return self._last_widget.key_callback(widget, event)
+            return False
+        window.connect('key-press-event', key_press_callback)
+        window.add_events(gtk.gdk.KEY_PRESS_MASK)
+
+    def update_status(self, row_name, new_status):
+        '''Updates status in display_dict.'''
+        if self.display_dict[row_name]['is_standard_status']:
+            result_map = {
+                True: ful.PASSED,
+                False: ful.FAILED,
+                None: ful.UNTESTED
+            }
+            assert new_status in result_map, 'Unknown result'
+            self.display_dict[row_name]['status'] = result_map[new_status]
+        else:
+            self.display_dict[row_name]['status'] = str(new_status)
+
+    def generate_final_result(self):
+        '''Generates the result from _results_to_check.'''
+        self._result = all(
+           ful.PASSED == self.display_dict[var]['status']
+           for var in self._results_to_check)
+        self.update_status('result', self._result)
+
+    def reset_status_rows(self):
+        '''Resets status row.'''
+        for name, _, _ in self._status_rows:
+            self.update_status(name, None)
+
+    def start_state_machine(self, start_state):
+        # Setup the initial display.
+        self.test_widget = gtk.VBox()
+        # Call advance_state to switch to start_state.
+        self.advance_state(new_state=start_state)
+
+        ful.run_test_widget(
+                self.job,
+                self.test_widget,
+                window_registration_callback=self._register_callbacks)
+
+    def run_once(self):
+        raise NotImplementedError
diff --git a/py/test/state_unittest.py b/py/test/state_unittest.py
new file mode 100644
index 0000000..62be135
--- /dev/null
+++ b/py/test/state_unittest.py
@@ -0,0 +1,40 @@
+#!/usr/bin/python -u
+#
+# Copyright (c) 2012 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 unittest
+
+import factory_common
+from autotest_lib.client.cros.factory.state import PathResolver
+
+
+class PathResolverTest(unittest.TestCase):
+    def testWithRoot(self):
+        resolver = PathResolver()
+        resolver.AddPath('/', '/root')
+        resolver.AddPath('/a/b', '/c/d')
+        resolver.AddPath('/a', '/e')
+
+        for url_path, expected_local_path in (
+            ('/', '/root'),
+            ('/a/b', '/c/d'),
+            ('/a', '/e'),
+            ('/a/b/X', '/c/d/X'),
+            ('/a/X', '/e/X'),
+            ('/X', '/root/X'),
+            ('/X/', '/root/X/'),
+            ('/X/Y', '/root/X/Y'),
+            ('Blah', None)):
+            self.assertEqual(expected_local_path,
+                             resolver.Resolve(url_path))
+
+    def testNoRoot(self):
+        resolver = PathResolver()
+        resolver.AddPath('/a/b', '/c/d')
+        self.assertEqual(None, resolver.Resolve('/b'))
+        self.assertEqual('/c/d/X', resolver.Resolve('/a/b/X'))
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/py/test/task.py b/py/test/task.py
new file mode 100755
index 0000000..24ec2d9
--- /dev/null
+++ b/py/test/task.py
@@ -0,0 +1,131 @@
+#!/usr/bin/python -u
+#
+# Copyright (c) 2012 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.
+
+
+# DESCRIPTION :
+#
+# A library for factory tests with sub-tasks.
+
+
+import logging
+import collections
+
+# GTK modules
+import gobject
+import gtk
+
+import factory_common
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import ui
+
+
+def schedule(task, *args, **kargs):
+    """Schedules a task function to be executed one time in GTK context."""
+    def idle_callback():
+        task(*args, **kargs)
+        return False
+    gobject.idle_add(idle_callback)
+
+
+class FactoryTask(object):
+    """Base class for factory tasks."""
+
+    TaskSession = collections.namedtuple(
+            'TaskSession',
+            'remover, on_stop, widgets, window_connects, timeout_callbacks')
+
+    def _attach_to_task_manager(self, window, container, remover):
+        """Attaches to a task manager.
+
+        @param window: The window provided by task manager.
+        @param container: Container widget inside window.
+        @param remover: Callback to remove task.
+        """
+        assert not hasattr(self, "_task_session"), "Already attached before."
+        self.window = window
+        self.container = container
+        self._task_session = self.TaskSession(remover, self.stop, [], [], [])
+        # Hook self.stop
+        self.stop = self._detach_from_task_manager
+
+    def _detach_from_task_manager(self):
+        assert hasattr(self, "_task_session"), "Not attached yet."
+        self._task_session.on_stop()
+        for callback in self._task_session.timeout_callbacks:
+            gobject.source_remove(callback)
+        for callback in self._task_session.window_connects:
+            self.window.disconnect(callback)
+        for widget in self._task_session.widgets:
+            widget.hide()
+            self.container.remove(widget)
+        # Restore self.stop
+        self.stop = self._task_session.on_stop
+        remover = self._task_session.remover
+        del self._task_session
+        remover(self)
+
+    def add_widget(self, widget):
+        """Adds a widget to container (automatically removed on stop)."""
+        self._task_session.widgets.append(widget)
+        self.container.add(widget)
+        # For widgets created by ui.make_input_window.
+        if hasattr(widget, 'entry'):
+            widget.entry.grab_focus()
+
+    def connect_window(self, *args, **kargs):
+        """Connects a window event (automatically cleared on stop)."""
+        self._task_session.window_connects.append(
+                self.window.connect(*args, **kargs))
+
+    def add_timeout(self, *args, **kargs):
+        """Adds a timeout callback (automatically deleted on stop)."""
+        self._task_session.timeout_callbacks.append(
+                gobject.timeout_add(*args, **kargs))
+
+    def start(self):
+        """The procedure for preparing widgets and threads."""
+        logging.debug("%s: started.", self.__class__.__name__)
+
+    def stop(self):
+        """The procedure to notify task manager to terminate current task."""
+        logging.debug("%s: stopped.", self.__class__.__name__)
+
+
+def run_factory_tasks(job, tasklist, on_complete=gtk.main_quit):
+    """Runs a list of factory tasks.
+
+    @param job: The job object from autotest "test.job".
+    @param tasklist: A list of FactoryTask objects to run.
+    @param on_complete: Callback when no more tasks to run.
+    """
+    session = {}
+
+    def find_next_task():
+        if tasklist:
+            task = tasklist[0]
+            logging.info("Starting task: %s", task.__class__.__name__)
+            task._attach_to_task_manager(session['window'], container,
+                                         remove_task)
+            task.start()
+            container.show_all()
+        else:
+            # No more tasks - try to complete.
+            logging.info("All tasks completed.")
+            schedule(on_complete)
+        return False
+
+    def remove_task(task):
+        factory.log("Stopping task: %s" % task.__class__.__name__)
+        tasklist.remove(task)
+        schedule(find_next_task)
+
+    def register_window(window):
+        session['window'] = window
+        schedule(find_next_task)
+
+    container = gtk.VBox()
+    ui.run_test_widget(job, container,
+                       window_registration_callback=register_window)
diff --git a/py/test/test_ui.py b/py/test/test_ui.py
new file mode 100644
index 0000000..e5c2f78
--- /dev/null
+++ b/py/test/test_ui.py
@@ -0,0 +1,223 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2010 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 logging
+import os
+import re
+import threading
+import traceback
+
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import TestState
+from autotest_lib.client.cros.factory.event import Event, EventClient
+
+
+class FactoryTestFailure(Exception):
+    pass
+
+
+class UI(object):
+    '''Web UI for a Goofy test.
+
+    You can set your test up in the following ways:
+
+    1. For simple tests with just Python+HTML+JS:
+
+         mytest.py
+         mytest.js    (automatically loaded)
+         mytest.html  (automatically loaded)
+
+       This works for autotests too:
+
+         factory_MyTest.py
+         factory_MyTest.js    (automatically loaded)
+         factory_MyTest.html  (automatically loaded)
+
+    2. If you have more files to include, like images
+       or other JavaScript libraries:
+
+         mytest.py
+         mytest_static/
+           mytest.js           (automatically loaded)
+           mytest.html         (automatically loaded)
+           some_js_library.js  (NOT automatically loaded;
+                                use <script src="some_js_lib.js">)
+           some_image.gif      (use <img src="some_image.gif">)
+
+    3. Same as #2, but with a directory just called "static" instead of
+       "mytest_static".  This is nicer if your test is already in a
+       directory that contains the test name (as for autotests).  So
+       for a test called factory_MyTest.py, you might have:
+
+         factory_MyTest/
+           factory_MyTest.py
+           static/
+             factory_MyTest.html  (automatically loaded)
+             factory_MyTest.js    (automatically loaded)
+             some_js_library.js
+             some_image.gif
+
+    Note that if you rename .html or .js files during development, you
+    may need to restart the server for your changes to take effect.
+    '''
+    def __init__(self):
+        self.lock = threading.RLock()
+        self.event_client = EventClient(callback=self._handle_event)
+        self.test = os.environ['CROS_FACTORY_TEST_PATH']
+        self.invocation = os.environ['CROS_FACTORY_TEST_INVOCATION']
+        self.event_handlers = {}
+
+        # Set base URL so that hrefs will resolve properly,
+        # and pull in Goofy CSS.
+        self.append_html('\n'.join([
+                    '<base href="/tests/%s/">' % self.test,
+                    ('<link rel="stylesheet" type="text/css" '
+                     'href="/goofy.css">')]))
+        self._setup_static_files(
+            os.path.realpath(traceback.extract_stack()[-2][0]))
+
+    def _setup_static_files(self, py_script):
+        # Get path to caller and register static files/directories.
+        base = os.path.splitext(py_script)[0]
+
+        # Directories we'll autoload .html and .js files from.
+        autoload_bases = [base]
+
+        # Find and register the static directory, if any.
+        static_dirs = filter(os.path.exists,
+                             [base + '_static',
+                              os.path.join(py_script, 'static')])
+        if len(static_dirs) > 1:
+            raise FactoryTestFailure('Cannot have both of %s - delete one!' %
+                                     static_dirs)
+        if static_dirs:
+            factory.get_state_instance().register_path(
+                '/tests/%s' % self.test, static_dirs[0])
+            autoload_bases.append(
+                os.path.join(static_dirs[0], os.path.basename(base)))
+
+        # Autoload .html and .js files.
+        for extension in ('js', 'html'):
+            autoload = filter(os.path.exists,
+                              [x + '.' + extension
+                               for x in autoload_bases])
+            if len(autoload) > 1:
+                raise FactoryTestFailure(
+                    'Cannot have both of %s - delete one!' %
+                    autoload)
+            if autoload:
+                factory.get_state_instance().register_path(
+                    '/tests/%s/%s' % (self.test, os.path.basename(autoload[0])),
+                    autoload[0])
+                if extension == 'html':
+                    self.append_html(open(autoload[0]).read())
+                else:
+                    self.append_html('<script src="%s"></script>' %
+                                     os.path.basename(autoload[0]))
+
+    def set_html(self, html, append=False):
+        '''Sets the UI in the test pane.'''
+        self.event_client.post_event(Event(Event.Type.SET_HTML,
+                                           test=self.test,
+                                           invocation=self.invocation,
+                                           html=html,
+                                           append=append))
+
+    def append_html(self, html):
+        '''Append to the UI in the test pane.'''
+        self.set_html(html, True)
+
+    def run_js(self, js, **kwargs):
+        '''Runs JavaScript code in the UI.
+
+        Args:
+            js: The JavaScript code to execute.
+            kwargs: Arguments to pass to the code; they will be
+                available in an "args" dict within the evaluation
+                context.
+
+        Example:
+            ui.run_js('alert(args.msg)', msg='The British are coming')
+        '''
+        self.event_client.post_event(Event(Event.Type.RUN_JS,
+                                           test=self.test,
+                                           invocation=self.invocation,
+                                           js=js, args=kwargs))
+
+    def call_js_function(self, name, *args):
+        '''Calls a JavaScript function in the test pane.
+
+        This will be run within window scope (i.e., 'this' will be the
+        test pane window).
+
+        Args:
+            name: The name of the function to execute.
+            args: Arguments to the function.
+        '''
+        self.event_client.post_event(Event(Event.Type.CALL_JS_FUNCTION,
+                                           test=self.test,
+                                           invocation=self.invocation,
+                                           name=name, args=args))
+
+    def add_event_handler(self, subtype, handler):
+        '''Adds an event handler.
+
+        Args:
+            subtype: The test-specific type of event to be handled.
+            handler: The handler to invoke with a single argument (the event
+                object).
+        '''
+        self.event_handlers.setdefault(subtype, []).append(handler)
+
+    def url_for_file(self, path):
+        '''Returns a URL that can be used to serve a local file.
+
+        Args:
+          path: path to the local file
+
+        Returns:
+          url: A (possibly relative) URL that refers to the file
+        '''
+        return factory.get_state_instance().url_for_file(path)
+
+    def url_for_data(self, mime_type, data, expiration=None):
+        '''Returns a URL that can be used to serve a static collection
+        of bytes.
+
+        Args:
+          mime_type: MIME type for the data
+          data: Data to serve
+          expiration_secs: If not None, the number of seconds in which
+            the data will expire.
+        '''
+        return factory.get_state_instance().url_for_data(
+            mime_type, data, expiration)
+
+    def run(self):
+        '''Runs the test UI, waiting until the test completes.'''
+        event = self.event_client.wait(
+            lambda event:
+                (event.type == Event.Type.END_TEST and
+                 event.invocation == self.invocation and
+                 event.test == self.test))
+        logging.info('Received end test event %r', event)
+        self.event_client.close()
+
+        if event.status == TestState.PASSED:
+            pass
+        elif event.status == TestState.FAILED:
+            raise FactoryTestFailure(event.error_msg)
+        else:
+            raise ValueError('Unexpected status in event %r' % event)
+
+    def _handle_event(self, event):
+        '''Handles an event sent by a test UI.'''
+        if (event.type == Event.Type.TEST_UI_EVENT and
+            event.test == self.test and
+            event.invocation == self.invocation):
+            with self.lock:
+                for handler in self.event_handlers.get(event.subtype, []):
+                    handler(event)
diff --git a/py/test/ui.py b/py/test/ui.py
new file mode 100755
index 0000000..85c325d
--- /dev/null
+++ b/py/test/ui.py
@@ -0,0 +1,1173 @@
+#!/usr/bin/python -u
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2010 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.
+
+
+# DESCRIPTION :
+#
+# This library provides convenience routines to launch factory tests.
+# This includes support for drawing the test widget in a window at the
+# proper location, grabbing control of the mouse, and making the mouse
+# cursor disappear.
+#
+# This UI is intended to be used by the factory autotest suite to
+# provide factory operators feedback on test status and control over
+# execution order.
+#
+# In short, the UI is composed of a 'console' panel on the bottom of
+# the screen which displays the autotest log, and there is also a
+# 'test list' panel on the right hand side of the screen.  The
+# majority of the screen is dedicated to tests, which are executed in
+# seperate processes, but instructed to display their own UIs in this
+# dedicated area whenever possible.  Tests in the test list are
+# executed in order by default, but can be activated on demand via
+# associated keyboard shortcuts.  As tests are run, their status is
+# color-indicated to the operator -- greyed out means untested, yellow
+# means active, green passed and red failed.
+
+import logging
+import os
+import re
+import string
+import subprocess
+import sys
+import threading
+import time
+from itertools import count, izip, product
+from optparse import OptionParser
+
+# GTK and X modules
+import gobject
+import gtk
+import pango
+
+# Guard loading Xlib because it is currently not available in the
+# image build process host-depends list.  Failure to load in
+# production should always manifest during regular use.
+try:
+    from Xlib import X
+    from Xlib.display import Display
+except:
+    pass
+
+# Factory and autotest modules
+import factory_common
+from autotest_lib.client.common_lib import error
+from autotest_lib.client.cros import factory
+from autotest_lib.client.cros.factory import TestState
+from autotest_lib.client.cros.factory.event import Event, EventClient
+
+
+# For compatibility with tests before TestState existed
+ACTIVE = TestState.ACTIVE
+PASSED = TestState.PASSED
+FAILED = TestState.FAILED
+UNTESTED = TestState.UNTESTED
+
+# Arrow symbols
+SYMBOL_RIGHT_ARROW = u'\u25b8'
+SYMBOL_DOWN_ARROW = u'\u25bc'
+
+# Color definition
+BLACK = gtk.gdk.Color()
+RED =   gtk.gdk.Color(0xFFFF, 0, 0)
+GREEN = gtk.gdk.Color(0, 0xFFFF, 0)
+BLUE =  gtk.gdk.Color(0, 0, 0xFFFF)
+WHITE = gtk.gdk.Color(0xFFFF, 0xFFFF, 0xFFFF)
+LIGHT_GREEN = gtk.gdk.color_parse('light green')
+SEP_COLOR = gtk.gdk.color_parse('grey50')
+
+RGBA_GREEN_OVERLAY = (0, 0.5, 0, 0.6)
+RGBA_YELLOW_OVERLAY = (0.6, 0.6, 0, 0.6)
+RGBA_RED_OVERLAY = (0.5, 0, 0, 0.6)
+
+LABEL_COLORS = {
+    TestState.ACTIVE: gtk.gdk.color_parse('light goldenrod'),
+    TestState.PASSED: gtk.gdk.color_parse('pale green'),
+    TestState.FAILED: gtk.gdk.color_parse('tomato'),
+    TestState.UNTESTED: gtk.gdk.color_parse('dark slate grey')}
+
+LABEL_FONT = pango.FontDescription('courier new condensed 16')
+LABEL_LARGE_FONT = pango.FontDescription('courier new condensed 24')
+
+FAIL_TIMEOUT = 60
+
+MESSAGE_NO_ACTIVE_TESTS = (
+        "No more tests to run. To re-run items, press shortcuts\n"
+        "from the test list in right side or from following list:\n\n"
+        "Ctrl-Alt-A (Auto-Run):\n"
+        "  Test remaining untested items.\n\n"
+        "Ctrl-Alt-F (Re-run Failed):\n"
+        "  Re-test failed items.\n\n"
+        "Ctrl-Alt-R (Reset):\n"
+        "  Re-test everything.\n\n"
+        "Ctrl-Alt-Z (Information):\n"
+        "  Review test results and information.\n\n"
+        )
+
+USER_PASS_FAIL_SELECT_STR = (
+    'hit TAB to fail and ENTER to pass\n' +
+    '錯誤請按 TAB,成功請按 ENTER')
+# Resolution where original UI is designed for.
+_UI_SCREEN_WIDTH = 1280
+_UI_SCREEN_HEIGHT = 800
+
+_LABEL_STATUS_ROW_SIZE = (300, 30)
+_LABEL_EN_SIZE = (170, 35)
+_LABEL_ZH_SIZE = (70, 35)
+_LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
+_LABEL_ZH_FONT = pango.FontDescription('normal 12')
+_LABEL_T_SIZE = (40, 35)
+_LABEL_T_FONT = pango.FontDescription('arial ultra-condensed 10')
+_LABEL_UNTESTED_FG = gtk.gdk.color_parse('grey40')
+_LABEL_TROUGH_COLOR = gtk.gdk.color_parse('grey20')
+_LABEL_STATUS_SIZE = (140, 30)
+_LABEL_STATUS_FONT = pango.FontDescription(
+    'courier new bold extra-condensed 16')
+_OTHER_LABEL_FONT = pango.FontDescription('courier new condensed 20')
+
+_NO_ACTIVE_TEST_DELAY_MS = 500
+
+GLOBAL_HOT_KEY_EVENTS = {
+    'r': Event.Type.RESTART_TESTS,
+    'a': Event.Type.AUTO_RUN,
+    'f': Event.Type.RE_RUN_FAILED,
+    'z': Event.Type.REVIEW,
+    }
+try:
+    # Works only if X is available.
+    GLOBAL_HOT_KEY_MASK = X.ControlMask | X.Mod1Mask
+except:
+    pass
+
+# ---------------------------------------------------------------------------
+# Client Library
+
+
+# TODO(hungte) Replace gtk_lock by gtk.gdk.lock when it's availble (need pygtk
+# 2.2x, and we're now pinned by 2.1x)
+class _GtkLock(object):
+    __enter__ = gtk.gdk.threads_enter
+    def __exit__(*ignored):
+        gtk.gdk.threads_leave()
+
+
+gtk_lock = _GtkLock()
+
+
+def make_label(message, font=LABEL_FONT, fg=LIGHT_GREEN,
+               size=None, alignment=None):
+    """Returns a label widget.
+
+    A wrapper for gtk.Label. The unit of size is pixels under resolution
+    _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
+
+    @param message: A string to be displayed.
+    @param font: Font descriptor for the label.
+    @param fg: Foreground color.
+    @param size: Minimum size for this label.
+    @param alignment: Alignment setting.
+    @return: A label widget.
+    """
+    l = gtk.Label(message)
+    l.modify_font(font)
+    l.modify_fg(gtk.STATE_NORMAL, fg)
+    if size:
+        # Convert size according to the current resolution.
+        l.set_size_request(*convert_pixels(size))
+    if alignment:
+        l.set_alignment(*alignment)
+    return l
+
+
+def make_status_row(init_prompt,
+                    init_status,
+                    label_size=_LABEL_STATUS_ROW_SIZE,
+                    is_standard_status=True):
+    """Returns a widget that live updates prompt and status in a row.
+
+    Args:
+        init_prompt: The prompt label text.
+        init_status: The status label text.
+        label_size: The desired size of the prompt label and the status label.
+        is_standard_status: True to interpret status by the values defined by
+            LABEL_COLORS, and render text by corresponding color. False to
+            display arbitrary text without changing text color.
+
+    Returns:
+        1) A dict whose content is linked by the widget.
+        2) A widget to render dict content in "prompt:   status" format.
+    """
+    display_dict = {}
+    display_dict['prompt'] = init_prompt
+    display_dict['status'] = init_status
+    display_dict['is_standard_status'] = is_standard_status
+
+    def prompt_label_expose(widget, event):
+        prompt = display_dict['prompt']
+        widget.set_text(prompt)
+
+    def status_label_expose(widget, event):
+        status = display_dict['status']
+        widget.set_text(status)
+        if is_standard_status:
+            widget.modify_fg(gtk.STATE_NORMAL, LABEL_COLORS[status])
+
+    prompt_label = make_label(
+            init_prompt, size=label_size,
+            alignment=(0, 0.5))
+    delimiter_label = make_label(':', alignment=(0, 0.5))
+    status_label = make_label(
+            init_status, size=label_size,
+            alignment=(0, 0.5))
+
+    widget = gtk.HBox()
+    widget.pack_end(status_label, False, False)
+    widget.pack_end(delimiter_label, False, False)
+    widget.pack_end(prompt_label, False, False)
+
+    status_label.connect('expose_event', status_label_expose)
+    prompt_label.connect('expose_event', prompt_label_expose)
+    return display_dict, widget
+
+
+def convert_pixels(size):
+    """Converts a pair in pixel that is suitable for current resolution.
+
+    GTK takes pixels as its unit in many function calls. To maintain the
+    consistency of the UI in different resolution, a conversion is required.
+    Take current resolution and (_UI_SCREEN_WIDTH, _UI_SCREEN_HEIGHT) as
+    the original resolution, this function returns a pair of width and height
+    that is converted for current resolution.
+
+    Because pixels in negative usually indicates unspecified, no conversion
+    will be done for negative pixels.
+
+    In addition, the aspect ratio is not maintained in this function.
+
+    Usage Example:
+        width,_ = convert_pixels((20,-1))
+
+    @param size: A pair of pixels that designed under original resolution.
+    @return: A pair of pixels of (width, height) format.
+             Pixels returned are always integer.
+    """
+    return (int(float(size[0]) / _UI_SCREEN_WIDTH * gtk.gdk.screen_width()
+           if (size[0] > 0) else size[0]),
+           int(float(size[1]) / _UI_SCREEN_HEIGHT * gtk.gdk.screen_height()
+           if (size[1] > 0) else size[1]))
+
+
+def make_hsep(height=1):
+    """Returns a widget acts as a horizontal separation line.
+
+    The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
+    """
+    frame = gtk.EventBox()
+    # Convert height according to the current resolution.
+    frame.set_size_request(*convert_pixels((-1, height)))
+    frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
+    return frame
+
+
+def make_vsep(width=1):
+    """Returns a widget acts as a vertical separation line.
+
+    The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
+    """
+    frame = gtk.EventBox()
+    # Convert width according to the current resolution.
+    frame.set_size_request(*convert_pixels((width, -1)))
+    frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
+    return frame
+
+
+def make_countdown_widget(prompt=None, value=None, fg=LIGHT_GREEN):
+    if prompt is None:
+        prompt = 'time remaining / 剩餘時間: '
+    if value is None:
+        value = '%s' % FAIL_TIMEOUT
+    title = make_label(prompt, fg=fg, alignment=(1, 0.5))
+    countdown = make_label(value, fg=fg, alignment=(0, 0.5))
+    hbox = gtk.HBox()
+    hbox.pack_start(title)
+    hbox.pack_start(countdown)
+    eb = gtk.EventBox()
+    eb.modify_bg(gtk.STATE_NORMAL, BLACK)
+    eb.add(hbox)
+    return eb, countdown
+
+
+def is_chrome_ui():
+    return os.environ.get('CROS_UI') == 'chrome'
+
+
+def hide_cursor(gdk_window):
+    pixmap = gtk.gdk.Pixmap(None, 1, 1, 1)
+    color = gtk.gdk.Color()
+    cursor = gtk.gdk.Cursor(pixmap, pixmap, color, color, 0, 0)
+    gdk_window.set_cursor(cursor)
+
+
+def calc_scale(wanted_x, wanted_y):
+    (widget_size_x, widget_size_y) = factory.get_shared_data('test_widget_size')
+    scale_x = (0.9 * widget_size_x) / wanted_x
+    scale_y = (0.9 * widget_size_y) / wanted_y
+    scale = scale_y if scale_y < scale_x else scale_x
+    scale = 1 if scale > 1 else scale
+    factory.log('scale: %s' % scale)
+    return scale
+
+
+def trim(text, length):
+    if len(text) > length:
+        text = text[:length-3] + '...'
+    return text
+
+
+class InputError(ValueError):
+    """Execption for input window callbacks to change status text message."""
+    pass
+
+
+def make_input_window(prompt=None,
+                      init_value=None,
+                      msg_invalid=None,
+                      font=None,
+                      on_validate=None,
+                      on_keypress=None,
+                      on_complete=None):
+    """Creates a widget to prompt user for a valid string.
+
+    @param prompt: A string to be displayed. None for default message.
+    @param init_value: Initial value to be set.
+    @param msg_invalid: Status string to display when input is invalid. None for
+        default message.
+    @param font: Font specification (string or pango.FontDescription) for label
+        and entry. None for default large font.
+    @param on_validate: A callback function to validate if the input from user
+        is valid. None for allowing any non-empty input. Any ValueError or
+        ui.InputError raised during execution in on_validate will be displayed
+        in bottom status.
+    @param on_keypress: A callback function when each keystroke is hit.
+    @param on_complete: A callback function when a valid string is passed.
+        None to stop (gtk.main_quit).
+    @return: A widget with prompt, input entry, and status label. To access
+        these elements, use attribute 'prompt', 'entry', and 'label'.
+    """
+    DEFAULT_MSG_INVALID = "Invalid input / 輸入不正確"
+    DEFAULT_PROMPT = "Enter Data / 輸入資料:"
+
+    def enter_callback(entry):
+        text = entry.get_text()
+        try:
+            if (on_validate and (not on_validate(text))) or (not text.strip()):
+                raise ValueError(msg_invalid)
+            on_complete(text) if on_complete else gtk.main_quit()
+        except ValueError as e:
+            gtk.gdk.beep()
+            status_label.set_text('ERROR: %s' % e.message)
+        return True
+
+    def key_press_callback(entry, key):
+        status_label.set_text('')
+        if on_keypress:
+            return on_keypress(entry, key)
+        return False
+
+    # Populate default parameters
+    if msg_invalid is None:
+        msg_invalid = DEFAULT_MSG_INVALID
+
+    if prompt is None:
+        prompt = DEFAULT_PROMPT
+
+    if font is None:
+        font = LABEL_LARGE_FONT
+    elif not isinstance(font, pango.FontDescription):
+        font = pango.FontDescription(font)
+
+    widget = gtk.VBox()
+    label = make_label(prompt, font=font)
+    status_label = make_label('', font=font)
+    entry = gtk.Entry()
+    entry.modify_font(font)
+    entry.connect("activate", enter_callback)
+    entry.connect("key_press_event", key_press_callback)
+    if init_value:
+        entry.set_text(init_value)
+    widget.modify_bg(gtk.STATE_NORMAL, BLACK)
+    status_label.modify_fg(gtk.STATE_NORMAL, RED)
+    widget.add(label)
+    widget.pack_start(entry)
+    widget.pack_start(status_label)
+
+    widget.entry = entry
+    widget.status = status_label
+    widget.prompt = label
+
+    # TODO(itspeter) Replace deprecated get_entry by widget.entry.
+    # Method for getting the entry.
+    widget.get_entry = lambda : entry
+    return widget
+
+
+def make_summary_box(tests, state_map, rows=15):
+    '''Creates a widget display status of a set of test.
+
+    @param tests: A list of FactoryTest nodes whose status (and children's
+        status) should be displayed.
+    @param state_map: The state map as provide by the state instance.
+    @param rows: The number of rows to display.
+    @return: A tuple (widget, label_map), where widget is the widget, and
+        label_map is a map from each test to the corresponding label.
+    '''
+    LABEL_EN_SIZE = (170, 35)
+    LABEL_EN_SIZE_2 = (450, 25)
+    LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
+
+    all_tests = sum([list(t.walk(in_order=True)) for t in tests], [])
+    columns = len(all_tests) / rows + (len(all_tests) % rows != 0)
+
+    info_box = gtk.HBox()
+    info_box.set_spacing(20)
+    for status in (TestState.ACTIVE, TestState.PASSED,
+                   TestState.FAILED, TestState.UNTESTED):
+        label = make_label(status,
+                               size=LABEL_EN_SIZE,
+                               font=LABEL_EN_FONT,
+                               alignment=(0.5, 0.5),
+                               fg=LABEL_COLORS[status])
+        info_box.pack_start(label, False, False)
+
+    vbox = gtk.VBox()
+    vbox.set_spacing(20)
+    vbox.pack_start(info_box, False, False)
+
+    label_map = {}
+
+    if all_tests:
+        status_table = gtk.Table(rows, columns, True)
+        for (j, i), t in izip(product(xrange(columns), xrange(rows)),
+                              all_tests):
+            msg_en = '  ' * (t.depth() - 1) + t.label_en
+            msg_en = trim(msg_en, 12)
+            if t.label_zh:
+                msg = '{0:<12} ({1})'.format(msg_en, t.label_zh)
+            else:
+                msg = msg_en
+            status = state_map[t].status
+            status_label = make_label(msg,
+                                      size=LABEL_EN_SIZE_2,
+                                      font=LABEL_EN_FONT,
+                                      alignment=(0.0, 0.5),
+                                      fg=LABEL_COLORS[status])
+            label_map[t] = status_label
+            status_table.attach(status_label, j, j+1, i, i+1)
+        vbox.pack_start(status_table, False, False)
+
+    return vbox, label_map
+
+
+def run_test_widget(dummy_job, test_widget,
+                    invisible_cursor=True,
+                    window_registration_callback=None,
+                    cleanup_callback=None):
+    test_widget_size = factory.get_shared_data('test_widget_size')
+
+    window = gtk.Window(gtk.WINDOW_TOPLEVEL)
+    window.modify_bg(gtk.STATE_NORMAL, BLACK)
+    window.set_size_request(*test_widget_size)
+
+    test_widget_position = factory.get_shared_data('test_widget_position')
+    if test_widget_position:
+        window.move(*test_widget_position)
+
+    def show_window():
+        window.show()
+        window.window.raise_()  # pylint: disable=E1101
+        if is_chrome_ui():
+            window.present()
+            window.window.set_cursor(gtk.gdk.Cursor(gtk.gdk.LEFT_PTR))
+        else:
+            gtk.gdk.pointer_grab(window.window, confine_to=window.window)
+            if invisible_cursor:
+                hide_cursor(window.window)
+
+    test_path = factory.get_current_test_path()
+
+    def handle_event(event):
+        if (event.type == Event.Type.STATE_CHANGE and
+            test_path and event.path == test_path and
+            event.state.visible):
+            show_window()
+
+    event_client = EventClient(
+            callback=handle_event, event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
+
+    align = gtk.Alignment(xalign=0.5, yalign=0.5)
+    align.add(test_widget)
+
+    window.add(align)
+    for c in window.get_children():
+        # Show all children, but not the window itself yet.
+        c.show_all()
+
+    if window_registration_callback is not None:
+        window_registration_callback(window)
+
+    # Show the window if it is the visible test, or if the test_path is not
+    # available (e.g., run directly from the command line).
+    if (not test_path) or (
+        TestState.from_dict_or_object(
+            factory.get_state_instance().get_test_state(test_path)).visible):
+        show_window()
+    else:
+        window.hide()
+
+    # When gtk.main() is running, it ignores all uncaught exceptions, which is
+    # not preferred by most of our factory tests.  To prevent writing special
+    # function raising errors, we hook top level exception handler to always
+    # leave GTK main and raise exception again.
+
+    def exception_hook(exc_type, value, traceback):
+       # Prevent re-entrant.
+       sys.excepthook = old_excepthook
+       session['exception'] = (exc_type, value, traceback)
+       gobject.idle_add(gtk.main_quit)
+       return old_excepthook(exc_type, value, traceback)
+
+    session = {}
+    old_excepthook = sys.excepthook
+    sys.excepthook = exception_hook
+
+    gtk.main()
+
+    if not is_chrome_ui():
+        gtk.gdk.pointer_ungrab()
+
+    if cleanup_callback is not None:
+        cleanup_callback()
+
+    del event_client
+
+    sys.excepthook = old_excepthook
+    exc_info = session.get('exception')
+    if exc_info is not None:
+       logging.error(exc_info[0], exc_info=exc_info)
+       raise error.TestError(exc_info[1])
+
+
+
+# ---------------------------------------------------------------------------
+# Server Implementation
+
+
+class Console(object):
+    '''Display a progress log.  Implemented by launching an borderless
+    xterm at a strategic location, and running tail against the log.'''
+
+    def __init__(self, allocation):
+        # Specify how many lines and characters per line are displayed.
+        XTERM_DISPLAY_LINES = 13
+        XTERM_DISPLAY_CHARS = 120
+        # Extra space reserved for pixels between lines.
+        XTERM_RESERVED_LINES = 3
+
+        xterm_coords = '%dx%d+%d+%d' % (XTERM_DISPLAY_CHARS,
+                                        XTERM_DISPLAY_LINES,
+                                        allocation.x,
+                                        allocation.y)
+        xterm_reserved_height = gtk.gdk.screen_height() - allocation.y
+        font_size = int(float(xterm_reserved_height) / (XTERM_DISPLAY_LINES +
+                                                        XTERM_RESERVED_LINES))
+        logging.info('xterm_reserved_height = %d' % xterm_reserved_height)
+        logging.info('font_size = %d' % font_size)
+        logging.info('xterm_coords = %s', xterm_coords)
+        xterm_opts = ('-bg black -fg lightgray -bw 0 -g %s' % xterm_coords)
+        xterm_cmd = (
+            ['urxvt'] + xterm_opts.split() +
+            ['-fn', 'xft:DejaVu Sans Mono:pixelsize=%s' % font_size] +
+            ['-e', 'bash'] +
+            ['-c', 'tail -f "%s"' % factory.CONSOLE_LOG_PATH])
+        logging.info('xterm_cmd = %s', xterm_cmd)
+        self._proc = subprocess.Popen(xterm_cmd)
+
+    def __del__(self):
+        logging.info('console_proc __del__')
+        self._proc.kill()
+
+
+class TestLabelBox(gtk.EventBox):  # pylint: disable=R0904
+
+    def __init__(self, test):
+        gtk.EventBox.__init__(self)
+        self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[TestState.UNTESTED])
+        self._is_group = test.is_group()
+        depth = len(test.get_ancestor_groups())
+        self._label_text = ' %s%s%s' % (
+                ' ' * depth,
+                SYMBOL_RIGHT_ARROW if self._is_group else ' ',
+                test.label_en)
+        if self._is_group:
+            self._label_text_collapsed = ' %s%s%s' % (
+                    ' ' * depth,
+                    SYMBOL_DOWN_ARROW if self._is_group else '',
+                    test.label_en)
+        self._label_en = make_label(
+            self._label_text, size=_LABEL_EN_SIZE,
+            font=_LABEL_EN_FONT, alignment=(0, 0.5),
+            fg=_LABEL_UNTESTED_FG)
+        self._label_zh = make_label(
+            test.label_zh, size=_LABEL_ZH_SIZE,
+            font=_LABEL_ZH_FONT, alignment=(0.5, 0.5),
+            fg=_LABEL_UNTESTED_FG)
+        self._label_t = make_label(
+            '', size=_LABEL_T_SIZE, font=_LABEL_T_FONT,
+            alignment=(0.5, 0.5), fg=BLACK)
+        hbox = gtk.HBox()
+        hbox.pack_start(self._label_en, False, False)
+        hbox.pack_start(self._label_zh, False, False)
+        hbox.pack_start(self._label_t, False, False)
+        vbox = gtk.VBox()
+        vbox.pack_start(hbox, False, False)
+        vbox.pack_start(make_hsep(), False, False)
+        self.add(vbox)
+        self._status = None
+
+    def set_shortcut(self, shortcut):
+        if shortcut is None:
+            return
+        self._label_t.set_text('C-%s' % shortcut.upper())
+        attrs = self._label_en.get_attributes() or pango.AttrList()
+        attrs.filter(lambda attr: attr.type == pango.ATTR_UNDERLINE)
+        index_hotkey = self._label_en.get_text().upper().find(shortcut.upper())
+        if index_hotkey != -1:
+            attrs.insert(pango.AttrUnderline(
+                pango.UNDERLINE_LOW, index_hotkey, index_hotkey + 1))
+            attrs.insert(pango.AttrWeight(
+                pango.WEIGHT_BOLD, index_hotkey, index_hotkey + 1))
+        self._label_en.set_attributes(attrs)
+        self.queue_draw()
+
+    def update(self, status):
+        if self._status == status:
+            return
+        self._status = status
+        label_fg = (_LABEL_UNTESTED_FG if status == TestState.UNTESTED
+                    else BLACK)
+        if self._is_group:
+            self._label_en.set_text(
+                    self._label_text_collapsed if status == TestState.ACTIVE
+                    else self._label_text)
+
+        for label in [self._label_en, self._label_zh, self._label_t]:
+            label.modify_fg(gtk.STATE_NORMAL, label_fg)
+        self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[status])
+        self.queue_draw()
+
+
+class ReviewInformation(object):
+
+    LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
+    TAB_BORDER = 20
+
+    def __init__(self, test_list):
+        self.test_list = test_list
+
+    def make_error_tab(self, test, state):
+        msg = '%s (%s)\n%s' % (test.label_en, test.label_zh,
+                               str(state.error_msg))
+        label = make_label(msg, font=self.LABEL_EN_FONT, alignment=(0.0, 0.0))
+        label.set_line_wrap(True)
+        frame = gtk.Frame()
+        frame.add(label)
+        return frame
+
+    def make_widget(self):
+        bg_color = gtk.gdk.Color(0x1000, 0x1000, 0x1000)
+        self.notebook = gtk.Notebook()
+        self.notebook.modify_bg(gtk.STATE_NORMAL, bg_color)
+
+        test_list = self.test_list
+        state_map = test_list.get_state_map()
+        tab, _ = make_summary_box([test_list], state_map)
+        tab.set_border_width(self.TAB_BORDER)
+        self.notebook.append_page(tab, make_label('Summary'))
+
+        for i, t in izip(
+            count(1),
+            [t for t in test_list.walk()
+             if state_map[t].status == factory.TestState.FAILED
+             and t.is_leaf()]):
+            tab = self.make_error_tab(t, state_map[t])
+            tab.set_border_width(self.TAB_BORDER)
+            self.notebook.append_page(tab, make_label('#%02d' % i))
+
+        prompt = 'Review: Test Status Information'
+        if self.notebook.get_n_pages() > 1:
+            prompt += '\nPress left/right to change tabs'
+
+        control_label = make_label(prompt, font=self.LABEL_EN_FONT,
+                                   alignment=(0.5, 0.5))
+        vbox = gtk.VBox()
+        vbox.set_spacing(self.TAB_BORDER)
+        vbox.pack_start(control_label, False, False)
+        vbox.pack_start(self.notebook, False, False)
+        vbox.show_all()
+        vbox.grab_focus = self.notebook.grab_focus
+        return vbox
+
+
+class TestDirectory(gtk.VBox):
+    '''Widget containing a list of tests, colored by test status.
+
+    This is the widget corresponding to the RHS test panel.
+
+    Attributes:
+      _label_map: Dict of test path to TestLabelBox objects.  Should
+          contain an entry for each test that has been visible at some
+          time.
+      _visible_status: List of (test, status) pairs reflecting the
+          last refresh of the set of visible tests.  This is used to
+          rememeber what tests were active, to allow implementation of
+          visual refresh only when new active tests appear.
+      _shortcut_map: Dict of keyboard shortcut key to test path.
+          Tracks the current set of keyboard shortcut mappings for the
+          visible set of tests.  This will change when the visible
+          test set changes.
+    '''
+
+    def __init__(self, test_list):
+        gtk.VBox.__init__(self)
+        self.set_spacing(0)
+        self._label_map = {}
+        self._visible_status = []
+        self._shortcut_map = {}
+        self._hard_shortcuts = set(
+            test.kbd_shortcut for test in test_list.walk()
+            if test.kbd_shortcut is not None)
+
+    def _get_test_label(self, test):
+        if test.path in self._label_map:
+            return self._label_map[test.path]
+        label_box = TestLabelBox(test)
+        self._label_map[test.path] = label_box
+        return label_box
+
+    def _remove_shortcut(self, path):
+        reverse_map = dict((v, k) for k, v in self._shortcut_map.items())
+        if path not in reverse_map:
+            logging.error('Removal of non-present shortcut for %s' % path)
+            return
+        shortcut = reverse_map[path]
+        del self._shortcut_map[shortcut]
+
+    def _add_shortcut(self, test):
+        shortcut = test.kbd_shortcut
+        if shortcut in self._shortcut_map:
+            logging.error('Shortcut %s already in use by %s; cannot apply to %s'
+                          % (shortcut, self._shortcut_map[shortcut], test.path))
+            shortcut = None
+        if shortcut is None:
+            # Find a suitable shortcut.  For groups, use numbers.  For
+            # regular tests, use alpha (letters).
+            if test.is_group():
+                gen = (x for x in string.digits if x not in self._shortcut_map)
+            else:
+                gen = (x for x in test.label_en.lower() + string.lowercase
+                       if x.isalnum() and x not in self._shortcut_map
+                       and x not in self._hard_shortcuts)
+            shortcut = next(gen, None)
+        if shortcut is None:
+            logging.error('Unable to find shortcut for %s' % test.path)
+            return
+        self._shortcut_map[shortcut] = test.path
+        return shortcut
+
+    def handle_xevent(self, dummy_src, dummy_cond,
+                      xhandle, keycode_map, event_client):
+        for dummy_i in range(0, xhandle.pending_events()):
+            xevent = xhandle.next_event()
+            if xevent.type != X.KeyPress:
+                continue
+            keycode = xevent.detail
+            if keycode not in keycode_map:
+                logging.warning('Ignoring unknown keycode %r' % keycode)
+                continue
+            shortcut = keycode_map[keycode]
+
+            if (xevent.state & GLOBAL_HOT_KEY_MASK == GLOBAL_HOT_KEY_MASK):
+                event_type = GLOBAL_HOT_KEY_EVENTS.get(shortcut)
+                if event_type:
+                    event_client.post_event(Event(event_type))
+                else:
+                    logging.warning('Unbound global hot key %s', key)
+            else:
+                if shortcut not in self._shortcut_map:
+                    logging.warning('Ignoring unbound shortcut %r' % shortcut)
+                    continue
+                test_path = self._shortcut_map[shortcut]
+                event_client.post_event(Event(Event.Type.SWITCH_TEST,
+                                              path=test_path))
+        return True
+
+    def update(self, new_test_status):
+        '''Refresh the RHS test list to show current status and active groups.
+
+        Refresh the set of visible tests only when new active tests
+        arise.  This avoids visual volatility when switching between
+        tests (intervals where no test is active).  Also refresh at
+        initial startup.
+
+        Args:
+          new_test_status: A list of (test, status) tuples.  The tests
+              order should match how they should be displayed in the
+              directory (rhs panel).
+        '''
+        old_active = set(t for t, s in self._visible_status
+                         if s == TestState.ACTIVE)
+        new_active = set(t for t, s in new_test_status
+                         if s == TestState.ACTIVE)
+        new_visible = set(t for t, s in new_test_status)
+        old_visible = set(t for t, s in self._visible_status)
+
+        if old_active and not new_active - old_active:
+            # No new active tests, so do not change the displayed test
+            # set, only update the displayed status for currently
+            # visible tests.  Not updating _visible_status allows us
+            # to remember the last set of active tests.
+            for test, _ in self._visible_status:
+                status = test.get_state().status
+                self._label_map[test.path].update(status)
+            return
+
+        self._visible_status = new_test_status
+
+        new_test_map = dict((t.path, t) for t, s in new_test_status)
+
+        for test in old_visible - new_visible:
+            label_box = self._label_map[test.path]
+            logging.debug('removing %s test label' % test.path)
+            self.remove(label_box)
+            self._remove_shortcut(test.path)
+
+        new_tests = new_visible - old_visible
+
+        for position, (test, status) in enumerate(new_test_status):
+            label_box = self._get_test_label(test)
+            if test in new_tests:
+                shortcut = self._add_shortcut(test)
+                label_box = self._get_test_label(test)
+                label_box.set_shortcut(shortcut)
+                logging.debug('adding %s test label (sortcut %r, pos %d)' %
+                              (test.path, shortcut, position))
+                self.pack_start(label_box, False, False)
+            self.reorder_child(label_box, position)
+            label_box.update(status)
+
+        self.show_all()
+
+
+
+class UiState(object):
+
+    WIDGET_NONE = 0
+    WIDGET_IDLE = 1
+    WIDGET_SUMMARY = 2
+    WIDGET_REVIEW = 3
+
+    def __init__(self, test_widget_box, test_directory_widget, test_list):
+        self._test_widget_box = test_widget_box
+        self._test_directory_widget = test_directory_widget
+        self._test_list = test_list
+        self._transition_count = 0
+        self._active_test_label_map = None
+        self._active_widget = self.WIDGET_NONE
+        self.update_test_state()
+
+    def show_idle_widget(self):
+        self.remove_state_widget()
+        self._test_widget_box.set(0.5, 0.5, 0.0, 0.0)
+        self._test_widget_box.set_padding(0, 0, 0, 0)
+        label = make_label(MESSAGE_NO_ACTIVE_TESTS,
+                           font=_OTHER_LABEL_FONT,
+                           alignment=(0.5, 0.5))
+        self._test_widget_box.add(label)
+        self._test_widget_box.show_all()
+        self._active_widget = self.WIDGET_IDLE
+
+    def show_summary_widget(self):
+        self.remove_state_widget()
+        state_map = self._test_list.get_state_map()
+        self._test_widget_box.set(0.5, 0.0, 0.0, 0.0)
+        self._test_widget_box.set_padding(40, 0, 0, 0)
+        vbox, self._active_test_label_map = make_summary_box(
+            [t for t in self._test_list.subtests
+             if state_map[t].status == TestState.ACTIVE],
+            state_map)
+        self._test_widget_box.add(vbox)
+        self._test_widget_box.show_all()
+        self._active_widget = self.WIDGET_SUMMARY
+
+    def show_review_widget(self):
+        self.remove_state_widget()
+        self._review_request = False
+        self._test_widget_box.set(0.5, 0.5, 0.0, 0.0)
+        self._test_widget_box.set_padding(0, 0, 0, 0)
+        widget = ReviewInformation(self._test_list).make_widget()
+        self._test_widget_box.add(widget)
+        self._test_widget_box.show_all()
+        widget.grab_focus()
+        self._active_widget = self.WIDGET_REVIEW
+
+    def remove_state_widget(self):
+        for child in self._test_widget_box.get_children():
+            child.hide()
+            self._test_widget_box.remove(child)
+        self._active_test_label_map = None
+        self._active_widget = self.WIDGET_NONE
+
+    def update_test_state(self):
+        state_map = self._test_list.get_state_map()
+        active_tests = set(
+            t for t in self._test_list.walk()
+            if t.is_leaf() and state_map[t].status == TestState.ACTIVE)
+        active_groups = set(g for t in active_tests
+                            for g in t.get_ancestor_groups())
+
+        def filter_visible_test_state(tests):
+            '''List currently visible tests and their status.
+
+            Visible means currently displayed in the RHS panel.
+            Visiblity is implied by being a top level test or having
+            membership in a group with at least one active test.
+
+            Returns:
+              A list of (test, status) tuples for all visible tests,
+              in the order they should be displayed.
+            '''
+            results = []
+            for test in tests:
+                if test.is_group():
+                    results.append((test, TestState.UNTESTED))
+                    if test not in active_groups:
+                        continue
+                    results += filter_visible_test_state(test.subtests)
+                else:
+                    results.append((test, state_map[test].status))
+            return results
+
+        visible_test_state = filter_visible_test_state(self._test_list.subtests)
+        self._test_directory_widget.update(visible_test_state)
+
+        if not active_tests:
+            # Display the idle or review information screen.
+            def waiting_for_transition():
+                return (self._active_widget not in
+                        [self.WIDGET_REVIEW, self.WIDGET_IDLE])
+
+            # For smooth transition between tests, idle widget if activated only
+            # after _NO_ACTIVE_TEST_DELAY_MS without state change.
+            def idle_transition_check(cookie):
+                if (waiting_for_transition() and
+                    cookie == self._transition_count):
+                    self._transition_count += 1
+                    self.show_idle_widget()
+                return False
+
+            if waiting_for_transition():
+                gobject.timeout_add(_NO_ACTIVE_TEST_DELAY_MS,
+                                    idle_transition_check,
+                                    self._transition_count)
+            return
+
+        self._transition_count += 1
+
+        if any(t.has_ui for t in active_tests):
+            # Remove the widget (if any) since there is an active test
+            # with a UI.
+            self.remove_state_widget()
+            return
+
+        if (self._active_test_label_map is not None and
+            all(t in self._active_test_label_map for t in active_tests)):
+            # All active tests are already present in the summary, so just
+            # update their states.
+            for test, label in self._active_test_label_map.iteritems():
+                label.modify_fg(
+                    gtk.STATE_NORMAL,
+                    LABEL_COLORS[state_map[test].status])
+            return
+
+        # No active UI; draw summary of current test states
+        self.show_summary_widget()
+
+
+def grab_shortcut_keys(disp, event_handler, event_client):
+    # We want to receive KeyPress events
+    root = disp.screen().root
+    root.change_attributes(event_mask = X.KeyPressMask)
+    shortcut_set = set(string.lowercase + string.digits)
+    keycode_map = {}
+    for mod, shortcut in ([(X.ControlMask, k) for k in shortcut_set] +
+                          [(GLOBAL_HOT_KEY_MASK, k)
+                           for k in GLOBAL_HOT_KEY_EVENTS] +
+                          [(X.Mod1Mask, 'Tab')]):  # Mod1 = Alt
+        keysym = gtk.gdk.keyval_from_name(shortcut)
+        keycode = disp.keysym_to_keycode(keysym)
+        keycode_map[keycode] = shortcut
+        root.grab_key(keycode, mod, 1, X.GrabModeAsync, X.GrabModeAsync)
+    # This flushes the XGrabKey calls to the server.
+    for dummy_x in range(0, root.display.pending_events()):
+        root.display.next_event()
+    gobject.io_add_watch(root.display, gobject.IO_IN, event_handler,
+                         root.display, keycode_map, event_client)
+
+
+def start_reposition_thread(title_regexp):
+    '''Starts a thread to reposition a client window once it appears.
+
+    This is useful to avoid blocking the console.
+
+    Args:
+      title_regexp: A regexp for the window's title (used to find the
+        window to reposition).
+    '''
+    test_widget_position = (
+        factory.get_shared_data('test_widget_position'))
+    if not test_widget_position:
+        return
+
+    def reposition():
+        display = Display()
+        root = display.screen().root
+        for i in xrange(50):
+            wins = [win for win in root.query_tree().children
+                    if re.match(title_regexp, win.get_wm_name())]
+            if wins:
+                wins[0].configure(x=test_widget_position[0],
+                                  y=test_widget_position[1])
+                display.sync()
+                return
+            # Wait 100 ms and try again.
+            time.sleep(.1)
+    thread = threading.Thread(target=reposition)
+    thread.daemon = True
+    thread.start()
+
+
+def main(test_list_path):
+    '''Starts the main UI.
+
+    This is launched by the autotest/cros/factory/client.
+    When operators press keyboard shortcuts, the shortcut
+    value is sent as an event to the control program.'''
+
+    test_list = None
+    ui_state = None
+    event_client = None
+
+    def handle_key_release_event(_, event):
+        logging.info('base ui key event (%s)', event.keyval)
+        return True
+
+    def handle_event(event):
+        if event.type == Event.Type.STATE_CHANGE:
+            ui_state.update_test_state()
+        elif event.type == Event.Type.REVIEW:
+            logging.info("Operator activates review information screen")
+            ui_state.show_review_widget()
+
+    test_list = factory.read_test_list(test_list_path)
+
+    window = gtk.Window(gtk.WINDOW_TOPLEVEL)
+    window.connect('destroy', lambda _: gtk.main_quit())
+    window.modify_bg(gtk.STATE_NORMAL, BLACK)
+
+    disp = Display()
+
+    event_client = EventClient(
+        callback=handle_event,
+        event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
+
+    screen = window.get_screen()
+    if (screen is None):
+        logging.info('ERROR: communication with the X server is not working, ' +
+                    'could not find a working screen.  UI exiting.')
+        sys.exit(1)
+
+    screen_size_str = os.environ.get('CROS_SCREEN_SIZE')
+    if screen_size_str:
+        match = re.match(r'^(\d+)x(\d+)$', screen_size_str)
+        assert match, 'CROS_SCREEN_SIZE should be {width}x{height}'
+        screen_size = (int(match.group(1)), int(match.group(2)))
+    else:
+        screen_size = (screen.get_width(), screen.get_height())
+    window.set_size_request(*screen_size)
+
+    test_directory = TestDirectory(test_list)
+
+    rhs_box = gtk.EventBox()
+    rhs_box.modify_bg(gtk.STATE_NORMAL, _LABEL_TROUGH_COLOR)
+    rhs_box.add(test_directory)
+
+    console_box = gtk.EventBox()
+    console_box.set_size_request(*convert_pixels((-1, 180)))
+    console_box.modify_bg(gtk.STATE_NORMAL, BLACK)
+
+    test_widget_box = gtk.Alignment()
+    test_widget_box.set_size_request(-1, -1)
+
+    lhs_box = gtk.VBox()
+    lhs_box.pack_end(console_box, False, False)
+    lhs_box.pack_start(test_widget_box)
+    lhs_box.pack_start(make_hsep(3), False, False)
+
+    base_box = gtk.HBox()
+    base_box.pack_end(rhs_box, False, False)
+    base_box.pack_end(make_vsep(3), False, False)
+    base_box.pack_start(lhs_box)
+
+    window.connect('key-release-event', handle_key_release_event)
+    window.add_events(gtk.gdk.KEY_RELEASE_MASK)
+
+    ui_state = UiState(test_widget_box, test_directory, test_list)
+
+    window.add(base_box)
+    window.show_all()
+
+    grab_shortcut_keys(disp, test_directory.handle_xevent, event_client)
+
+    hide_cursor(window.window)
+
+    test_widget_allocation = test_widget_box.get_allocation()
+    test_widget_size = (test_widget_allocation.width,
+                        test_widget_allocation.height)
+    factory.set_shared_data('test_widget_size', test_widget_size)
+
+    if not factory.in_chroot():
+        dummy_console = Console(console_box.get_allocation())
+
+    event_client.post_event(Event(Event.Type.UI_READY))
+
+    logging.info('cros/factory/ui setup done, starting gtk.main()...')
+    gtk.main()
+    logging.info('cros/factory/ui gtk.main() finished, exiting.')
+
+
+if __name__ == '__main__':
+    parser = OptionParser(usage='usage: %prog [options] TEST-LIST-PATH')
+    parser.add_option('-v', '--verbose', dest='verbose',
+                      action='store_true',
+                      help='Enable debug logging')
+    (options, args) = parser.parse_args()
+
+    if len(args) != 1:
+        parser.error('Incorrect number of arguments')
+
+    factory.init_logging('ui', verbose=options.verbose)
+    main(sys.argv[1])
diff --git a/py/test/unicode_to_string.py b/py/test/unicode_to_string.py
new file mode 100644
index 0000000..0f5ab46
--- /dev/null
+++ b/py/test/unicode_to_string.py
@@ -0,0 +1,47 @@
+#!/usr/bin/python -u
+#
+# Copyright (c) 2012 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.
+
+'''Wrappers to convert Unicode strings to UTF-8 strings.'''
+
+import types
+
+
+def UnicodeToString(obj):
+    '''Converts any Unicode strings in obj to UTF-8 strings.
+
+    Recurses into lists, dicts, and tuples in obj.
+    '''
+    if isinstance(obj, list):
+        return [UnicodeToString(x) for x in obj]
+    elif isinstance(obj, dict):
+        return dict((UnicodeToString(k), UnicodeToString(v))
+                    for k, v in obj.iteritems())
+    elif isinstance(obj, unicode):
+        return obj.encode('utf-8')
+    elif isinstance(obj, tuple):
+        return tuple(UnicodeToString(x) for x in obj)
+    elif isinstance(obj, set):
+        return set(UnicodeToString(x) for x in obj)
+    else:
+        return obj
+
+
+def UnicodeToStringArgs(function):
+    '''A function decorator that converts function's arguments from
+    Unicode to strings using UnicodeToString.
+    '''
+    return (lambda *args, **kwargs:
+                function(*UnicodeToString(args),
+                          **UnicodeToString(kwargs)))
+
+
+def UnicodeToStringClass(cls):
+    '''A class decorator that converts all arguments of all
+    methods in class from Unicode to strings using UnicodeToStringArgs.'''
+    for k, v in cls.__dict__.items():
+        if type(v) == types.FunctionType:
+            setattr(cls, k, UnicodeToStringArgs(v))
+    return cls
diff --git a/py/test/unicode_to_string_unittest.py b/py/test/unicode_to_string_unittest.py
new file mode 100644
index 0000000..eeb0456
--- /dev/null
+++ b/py/test/unicode_to_string_unittest.py
@@ -0,0 +1,92 @@
+#!/usr/bin/python -u
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2012 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 factory_common
+
+import unittest
+from autotest_lib.client.cros.factory.unicode_to_string \
+    import UnicodeToString, UnicodeToStringArgs, UnicodeToStringClass
+
+# My favorite character: 囧
+JIONG_UTF8 = '\xe5\x9b\xa7'
+
+class UnicodeToStringTest(unittest.TestCase):
+    def isSame(self, a, b):
+        '''Returns True if a and b are equal and the same type.
+
+        This is necessary because 'abc' == u'abc' but we want to distinguish
+        them.
+        '''
+        if a != b:
+            return False
+        elif type(a) != type(b):
+            return False
+        elif type(a) in [list, tuple]:
+            for x, y in zip(a, b):
+                if not self.isSame(x, y):
+                    return False
+        elif type(a) == set:
+            return self.isSame(sorted(list(a)), sorted(list(b)))
+        elif type(a) == dict:
+            for k in a:
+                if not self.isSame(a[k], b[k]):
+                    return False
+        return True
+
+    def assertSame(self, a, b):
+        self.assertTrue(self.isSame(a, b), 'isSame(%r,%r)' % (a, b))
+
+    def testAssertSame(self):
+        '''Makes sense that assertSame works properly.'''
+        self.assertSame('abc', 'abc')
+        self.assertRaises(AssertionError,
+                          lambda: self.assertSame('abc', u'abc'))
+        self.assertSame(['a'], ['a'])
+        self.assertRaises(AssertionError,
+                          lambda: self.assertSame(['a'], [u'a']))
+        self.assertSame(('a'), ('a'))
+        self.assertRaises(AssertionError,
+                          lambda: self.assertSame(('a'), (u'a')))
+        self.assertSame(set(['a']), set(['a']))
+        self.assertRaises(
+            AssertionError,
+            lambda: self.assertSame(set(['a']),
+                                    set([u'a'])))
+        self.assertSame({1: 'a'}, {1: 'a'})
+        self.assertRaises(AssertionError,
+                          lambda: self.assertSame({1: 'a'}, {1: u'a'}))
+
+    def testUnicodeToString(self):
+        self.assertSame(1, UnicodeToString(1))
+        self.assertSame('abc', UnicodeToString(u'abc'))
+        self.assertSame(JIONG_UTF8, UnicodeToString(u'囧'))
+
+    def testUnicodeToStringArgs(self):
+        @UnicodeToStringArgs
+        def func(*args, **kwargs):
+            return ('func', args, kwargs)
+
+        self.assertSame(('func', ('a',), {'b': 'c'}),
+                        func(u'a', b=u'c'))
+
+    def testUnicodeToStringClass(self):
+        @UnicodeToStringClass
+        class MyClass(object):
+            def f1(self, *args, **kwargs):
+                return ('f1', args, kwargs)
+            def f2(self, *args, **kwargs):
+                return ('f2', args, kwargs)
+
+        obj = MyClass()
+        self.assertSame(('f1', ('a',), {'b': 'c', 'd': set(['e'])}),
+                        obj.f1(u'a', b=u'c', d=set([u'e'])))
+        self.assertSame(('f2', ('a',), {'b': 'c', 'd': set(['e'])}),
+                        obj.f2(u'a', b=u'c', d=set([u'e'])))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/py/test/utils.py b/py/test/utils.py
new file mode 100644
index 0000000..acbfc30
--- /dev/null
+++ b/py/test/utils.py
@@ -0,0 +1,195 @@
+#!/usr/bin/python -u
+#
+# Copyright (c) 2012 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 array
+import fcntl
+import glob
+import logging
+import os
+import Queue
+import re
+import signal
+import subprocess
+import sys
+import time
+
+
+def TimeString(unix_time=None):
+    """Returns a time (using UTC) as a string.
+
+    The format is like ISO8601 but with milliseconds:
+
+      2012-05-22T14:15:08.123Z
+    """
+
+    t = unix_time or time.time()
+    return "%s.%03dZ" % (time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(t)),
+                         int((t - int(t)) * 1000))
+
+
+def in_chroot():
+    '''Returns True if currently in the chroot.'''
+    return 'CROS_WORKON_SRCROOT' in os.environ
+
+
+def in_qemu():
+    '''Returns True if running within QEMU.'''
+    return 'QEMU' in open('/proc/cpuinfo').read()
+
+
+def is_process_alive(pid):
+    '''
+    Returns true if the named process is alive and not a zombie.
+    '''
+    try:
+        with open("/proc/%d/stat" % pid) as f:
+            return f.readline().split()[2] != 'Z'
+    except IOError:
+        return False
+
+
+def kill_process_tree(process, caption):
+    '''
+    Kills a process and all its subprocesses.
+
+    @param process: The process to kill (opened with the subprocess module).
+    @param caption: A caption describing the process.
+    '''
+    # os.kill does not kill child processes. os.killpg kills all processes
+    # sharing same group (and is usually used for killing process tree). But in
+    # our case, to preserve PGID for autotest and upstart service, we need to
+    # iterate through each level until leaf of the tree.
+
+    def get_all_pids(root):
+        ps_output = subprocess.Popen(['ps','--no-headers','-eo','pid,ppid'],
+                                     stdout=subprocess.PIPE)
+        children = {}
+        for line in ps_output.stdout:
+            match = re.findall('\d+', line)
+            children.setdefault(int(match[1]), []).append(int(match[0]))
+        pids = []
+        def add_children(pid):
+            pids.append(pid)
+            map(add_children, children.get(pid, []))
+        add_children(root)
+        # Reverse the list to first kill children then parents.
+        # Note reversed(pids) will return an iterator instead of real list, so
+        # we must explicitly call pids.reverse() here.
+        pids.reverse()
+        return pids
+
+    pids = get_all_pids(process.pid)
+    for sig in [signal.SIGTERM, signal.SIGKILL]:
+        logging.info('Stopping %s (pid=%s)...', caption, sorted(pids))
+
+        for i in range(25):  # Try 25 times (200 ms between tries)
+            for pid in pids:
+                try:
+                    logging.info("Sending signal %s to %d", sig, pid)
+                    os.kill(pid, sig)
+                except OSError:
+                    pass
+            pids = filter(is_process_alive, pids)
+            if not pids:
+                return
+            time.sleep(0.2)  # Sleep 200 ms and try again
+
+    logging.warn('Failed to stop %s process. Ignoring.', caption)
+
+
+def are_shift_keys_depressed():
+    '''Returns True if both shift keys are depressed.'''
+    # From #include <linux/input.h>
+    KEY_LEFTSHIFT = 42
+    KEY_RIGHTSHIFT = 54
+
+    for kbd in glob.glob("/dev/input/by-path/*kbd"):
+        try:
+            f = os.open(kbd, os.O_RDONLY)
+        except OSError as e:
+            if in_chroot():
+                # That's OK; we're just not root
+                continue
+            else:
+                raise
+        buf = array.array('b', [0] * 96)
+
+        # EVIOCGKEY (from #include <linux/input.h>)
+        fcntl.ioctl(f, 0x80604518, buf)
+
+        def is_pressed(key):
+            return (buf[key / 8] & (1 << (key % 8))) != 0
+
+        if is_pressed(KEY_LEFTSHIFT) and is_pressed(KEY_RIGHTSHIFT):
+            return True
+
+    return False
+
+
+def var_log_messages_before_reboot(lines=100,
+                                   max_length=1024*1024,
+                                   path='/var/log/messages'):
+    '''Returns the last few lines in /var/log/messages
+    before the current boot.
+
+    Returns:
+        An array of lines.  Empty if the marker indicating kernel boot
+        could not be found.
+
+    Args:
+        lines: number of lines to return.
+        max_length: maximum amount of data at end of file to read.
+        path: path to /var/log/messages.
+    '''
+    offset = max(0, os.path.getsize(path) - max_length)
+    with open(path) as f:
+        f.seek(offset)
+        data = f.read()
+
+    # Find the last element matching the RE signaling kernel start.
+    matches = list(re.finditer(r'kernel:\s+\[\s+0\.\d+\] Linux version', data))
+    if not matches:
+        return []
+
+    match = matches[-1]
+    tail_lines = data[:match.start()].split('\n')
+    tail_lines.pop()  # Remove incomplete line at end
+
+    # Skip some common lines that may have been written before the Linux
+    # version.
+    while tail_lines and any(
+        re.search(x, tail_lines[-1])
+        for x in [r'0\.000000\]',
+                  r'rsyslogd.+\(re\)start',
+                  r'/proc/kmsg started']):
+        tail_lines.pop()
+
+    # Done!  Return the last few lines.
+    return tail_lines[-lines:]
+
+
+def DrainQueue(queue):
+    '''
+    Returns as many elements as can be obtained from a queue
+    without blocking.
+
+    (This may be no elements at all.)
+    '''
+    ret = []
+    while True:
+        try:
+            ret.append(queue.get_nowait())
+        except Queue.Empty:
+            break
+    return ret
+
+
+class Enum(frozenset):
+    '''An enumeration type.'''
+    def __getattr__(self, name):
+        if name in self:
+            return name
+        raise AttributeError
diff --git a/py/test/utils_unittest.py b/py/test/utils_unittest.py
new file mode 100644
index 0000000..ca3f351
--- /dev/null
+++ b/py/test/utils_unittest.py
@@ -0,0 +1,59 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2012 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 os
+import tempfile
+import unittest
+
+import factory_common
+from autotest_lib.client.cros.factory import utils
+
+
+EARLIER_VAR_LOG_MESSAGES = '''19:26:17 kernel: That's all, folks.
+19:26:56 kernel: [    0.000000] Initializing cgroup subsys cpuset
+19:26:56 kernel: [    0.000000] Initializing cgroup subsys cpu
+19:26:56 kernel: [    0.000000] Linux version blahblahblah
+'''
+
+VAR_LOG_MESSAGES = '''19:00:00 kernel: 7 p.m. and all's well.
+19:27:17 kernel: That's all, folks.
+19:27:17 kernel: Kernel logging (proc) stopped.
+19:27:56 kernel: imklog 4.6.2, log source = /proc/kmsg started.
+19:27:56 rsyslogd: [origin software="rsyslogd" blahblahblah] (re)start
+19:27:56 kernel: [    0.000000] Initializing cgroup subsys cpuset
+19:27:56 kernel: [    0.000000] Initializing cgroup subsys cpu
+19:27:56 kernel: [    0.000000] Linux version blahblahblah
+19:27:56 kernel: [    0.000000] Command line: blahblahblah
+'''
+
+class VarLogMessagesTest(unittest.TestCase):
+    def _GetMessages(self, data, lines):
+        with tempfile.NamedTemporaryFile() as f:
+            path = f.name
+            f.write(data)
+            f.flush()
+
+            return utils.var_log_messages_before_reboot(path=path, lines=lines)
+
+    def runTest(self):
+        self.assertEquals([
+                "19:27:17 kernel: That's all, folks.",
+                "19:27:17 kernel: Kernel logging (proc) stopped.",
+                ], self._GetMessages(VAR_LOG_MESSAGES, 2))
+        self.assertEquals([
+                "19:27:17 kernel: Kernel logging (proc) stopped.",
+                ], self._GetMessages(VAR_LOG_MESSAGES, 1))
+        self.assertEquals([
+                "19:00:00 kernel: 7 p.m. and all's well.",
+                "19:27:17 kernel: That's all, folks.",
+                "19:27:17 kernel: Kernel logging (proc) stopped.",
+                ], self._GetMessages(VAR_LOG_MESSAGES, 100))
+        self.assertEquals([
+                "19:26:17 kernel: That's all, folks.",
+                ], self._GetMessages(EARLIER_VAR_LOG_MESSAGES, 1))
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/sh/client b/sh/client
new file mode 100755
index 0000000..2e77c20
--- /dev/null
+++ b/sh/client
@@ -0,0 +1,90 @@
+#!/bin/sh
+# Copyright (c) 2011 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.
+
+set -e
+SCRIPT_BASE="$(readlink -f "$(dirname "$0")")"
+FACTORY_LOG_FILE=/var/log/factory.log
+
+SUITE_FACTORY="$(dirname "$0")"/../../site_tests/suite_Factory
+BOARD_SETUP="$(readlink -f "$SUITE_FACTORY")/board_setup_factory.sh"
+
+# Default args for Goofy.
+GOOFY_ARGS=""
+
+# Default implementation for factory_setup (no-op).  May be overriden
+# by board_setup_factory.sh.
+factory_setup() {
+    true
+}
+
+load_setup() {
+  # Load board-specific parameters, if any.
+  if [ -s $BOARD_SETUP ]; then
+    echo "Loading board-specific parameters..." 1>&2
+    . $BOARD_SETUP
+  fi
+
+  factory_setup
+}
+
+start_factory() {
+  load_setup
+  echo "
+    Starting factory program...
+
+    If you don't see factory window after more than one minute,
+    try to switch to VT2 (Ctrl-Alt-F2), log in, and check the messages by:
+      tail $FACTORY_LOG_FILE
+
+    If it keeps failing, try to reset by:
+      $SCRIPT_BASE/restart.sh -a
+  "
+
+  # Preload modules here
+  modprobe i2c-dev 2>/dev/null || true
+
+  # This script should be located in .../autotest/cros/factory.
+  cd "$SCRIPT_BASE/../.."
+  eval "$("$SCRIPT_BASE/startx.sh" 2>/var/log/startx.err)"
+  "$SCRIPT_BASE/goofy" $GOOFY_ARGS >>"$FACTORY_LOG_FILE" 2>&1
+}
+
+stop_factory() {
+  load_setup
+  # Try to kill X, and any other Python scripts, five times.
+  echo -n "Stopping factory."
+  for i in $(seq 5); do
+    pkill 'X|python' || break
+    sleep 1
+    echo -n "."
+  done
+
+  echo "
+
+    Factory tests terminated. To check error messages, try
+      tail $FACTORY_LOG_FILE
+
+    To restart, press Ctrl-Alt-F2, log in, and type:
+      $SCRIPT_BASE/restart.sh
+
+    If restarting does not work, try to reset by:
+      $SCRIPT_BASE/restart.sh -a
+    "
+}
+
+case "$1" in
+  "start" )
+    start_factory "$@"
+    ;;
+
+  "stop" )
+    stop_factory "$@"
+    ;;
+
+  * )
+    echo "Usage: $0 [start|stop]"
+    exit 1
+    ;;
+esac
diff --git a/sh/restart.sh b/sh/restart.sh
new file mode 100755
index 0000000..1d77c21
--- /dev/null
+++ b/sh/restart.sh
@@ -0,0 +1,106 @@
+#!/bin/sh
+# Copyright (c) 2010 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 script restarts factory test program.
+
+FACTORY_LOG_FILE=/var/log/factory.log
+FACTORY_START_TAG_FILE=/usr/local/autotest/factory_started
+FACTORY_CONTROL_FILE=/usr/local/autotest/control
+FACTORY_STATE_PREFIX=/var/log/factory_state
+
+SCRIPT="$0"
+
+# Restart without session ID, the parent process may be one of the
+# processes we plan to kill.
+if [ -z "$_DAEMONIZED" ]; then
+  _DAEMONIZED=TRUE setsid "$SCRIPT" "$@"
+  exit $?
+fi
+
+usage_help() {
+  echo "usage: $SCRIPT [options]
+    options:
+      -s | state:   clear state files, implies -r ( $FACTORY_STATE_PREFIX* )
+      -l | log:     backup and reset factory log files ( $FACTORY_LOG_FILE )
+      -c | control: refresh control file, implies -r ( $FACTORY_CONTROL_FILE )
+      -r | restart: clear factory start tag ( $FACTORY_START_TAG_FILE )
+      -a | all:     restart everything
+      -h | help:    this help screen
+  "
+}
+
+clear_files() {
+  local opt="$1"
+  local file="$2"
+  local is_multi="$3"
+  if [ -z "$opt" ]; then
+    return 0
+  fi
+  if [ -n "$is_multi" ]; then
+    echo -n "$file"* " "
+    rm -rf "$file"*  2>/dev/null
+  else
+    echo -n "$file "
+    rm -rf "$file" "$file.bak" 2>/dev/null
+  fi
+}
+
+while [ $# -gt 0 ]; do
+  opt="$1"
+  shift
+  case "$opt" in
+    -l | log )
+      opt_log=1
+      ;;
+    -s | state )
+      opt_state=1
+      opt_start_tag=1
+      ;;
+    -c | control )
+      opt_control=1
+      opt_start_tag=1
+      ;;
+    -r | restart )
+      opt_start_tag=1
+      ;;
+    -a | all )
+      opt_log=1
+      opt_state=1
+      opt_control=1
+      opt_start_tag=1
+      ;;
+    -h | help )
+      usage_help
+      exit 0
+      ;;
+    * )
+      echo "Unknown option: $opt"
+      usage_help
+      exit 1
+      ;;
+  esac
+done
+
+echo -n "Stopping factory test programs... "
+(pkill python; pkill X; killall /usr/bin/python) 2>/dev/null
+for sec in 3 2 1; do
+  echo -n "${sec} "
+  sleep 1
+done
+killall -9 /usr/bin/python 2>/dev/null
+echo "done."
+
+echo -n "Resetting files: "
+clear_files "$opt_log" "$FACTORY_LOG_FILE" ""
+clear_files "$opt_state" "$FACTORY_STATE_PREFIX" "1"
+clear_files "$opt_control" "$FACTORY_CONTROL_FILE" ""
+clear_files "$opt_start_tag" "$FACTORY_START_TAG_FILE" ""
+echo " done."
+
+echo "Restarting new factory test program..."
+# Ensure full stop, we don't want to have the same factory
+# process recycled after we've been killing bits of it.
+stop factory
+start factory
diff --git a/sh/startx.sh b/sh/startx.sh
new file mode 100755
index 0000000..22c5937
--- /dev/null
+++ b/sh/startx.sh
@@ -0,0 +1,93 @@
+#!/bin/sh
+
+# Copyright (c) 2010 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.
+
+XAUTH=/usr/bin/xauth
+XAUTH_FILE=/home/chronos/.Xauthority
+SERVER_READY=
+DISPLAY=":0"
+
+SUITE_FACTORY="$(dirname "$0")"/../../site_tests/suite_Factory
+BOARD_SETUP="$(readlink -f "$SUITE_FACTORY")/board_setup_x.sh"
+
+# Default X server parameters
+X_ARG="-r -s 0 -p 0 -dpms -nolisten tcp vt01 -auth ${XAUTH_FILE}"
+
+board_pre_setup() {
+  true
+}
+
+board_post_setup() {
+  true
+}
+
+setup_xauth() {
+  MCOOKIE=$(head -c 8 /dev/urandom | md5sum | cut -d' ' -f1)
+  ${XAUTH} -q -f ${XAUTH_FILE} add ${DISPLAY} . ${MCOOKIE}
+}
+
+setup_cursor() {
+  # The following logic is copied from /sbin/xstart.sh to initialize the
+  # touchpad device adequately.
+  if [ -d /home/chronos -a ! -f /home/chronos/.syntp_enable ] ; then
+    # serio_rawN devices come from the udev rules for the Synaptics binary
+    # driver throwing the PS/2 interface into raw mode.  If we find any
+    # that are TP devices, put them back into normal mode to use the default
+    # driver instead.
+    for P in /sys/class/misc/serio_raw*
+    do
+      # Note: It's OK if globbing fails, since the next line will drop us out
+      udevadm info -q env -p $P | grep -q ID_INPUT_TOUCHPAD=1 || continue
+      # Rescan everything; things that don't have another driver will just
+      # restart in serio_rawN mode again, since they won't be looking for
+      # the disable in their udev rules.
+      SERIAL_DEVICE=/sys$(dirname $(dirname $(udevadm info -q path -p $P)))
+      echo -n "rescan" > ${SERIAL_DEVICE}/drvctl
+    done
+  else
+    udevadm trigger
+    udevadm settle
+  fi
+}
+
+user1_handler () {
+  echo "X server ready..." 1>&2
+  SERVER_READY=y
+}
+
+start_x_server() {
+  trap user1_handler USR1
+  /bin/sh -c "
+    trap '' USR1 TTOU TTIN
+    exec /usr/bin/X $X_ARG 2>/var/log/factory.X.log" &
+
+  while [ -z ${SERVER_READY} ]; do
+    sleep .1
+  done
+}
+
+# Load board-specific parameters, and override any startup procedures.
+if [ -s $BOARD_SETUP ]; then
+  echo "Loading board-specific X parameters..." 1>&2
+  . $BOARD_SETUP
+fi
+
+board_pre_setup
+
+setup_xauth
+setup_cursor
+
+start_x_server
+
+export DISPLAY=${DISPLAY}
+export XAUTHORITY=${XAUTH_FILE}
+
+board_post_setup
+
+/sbin/initctl emit factory-ui-started
+cat /proc/uptime > /tmp/uptime-x-started
+
+echo "export DISPLAY=${DISPLAY}"
+echo "export XAUTHORITY=${XAUTH_FILE}"