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}"