devserver: Tests for CherryPy zero-port extensions.

This adds both unit tests, as well as extending integration tests, to
ensure that our CherryPy extensions module (cherrypy_ext) works as
intended.

BUG=chromium:322436
TEST=Unit + integration tests pass.

Change-Id: I93221d11d855cd4d78d39e34e587b5b24e9eb4d1
Reviewed-on: https://chromium-review.googlesource.com/186848
Tested-by: Gilad Arnold <garnold@chromium.org>
Reviewed-by: Chris Sosa <sosa@chromium.org>
Commit-Queue: Gilad Arnold <garnold@chromium.org>
diff --git a/cherrypy_ext_unittest.py b/cherrypy_ext_unittest.py
new file mode 100755
index 0000000..65a70fc
--- /dev/null
+++ b/cherrypy_ext_unittest.py
@@ -0,0 +1,77 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2014 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.
+
+"""Unit tests for cherrypy_ext."""
+
+import cherrypy
+import tempfile
+import unittest
+
+import mox
+
+import cherrypy_ext
+
+
+class CherrypyExtTest(mox.MoxTestBase):
+  """Tests for the cherrypy_ext module."""
+
+  def testPortFile(self):
+    """Check that PortFile correctly reports a bound port."""
+    with tempfile.NamedTemporaryFile(delete=False) as f:
+      portfile = f.name
+    bus = self.mox.CreateMock(cherrypy.engine)
+    self.mox.StubOutWithMock(bus, 'log')
+    bus.log(mox.IsA(str)).MultipleTimes()
+
+    cherrypy.server = self.mox.CreateMock(object)
+    cherrypy.server.httpserver = self.mox.CreateMock(object)
+    cherrypy.server.httpserver.socket = self.mox.CreateMock(object)
+    cherrypy.server.httpserver.socket.getsockname = None
+    self.mox.StubOutWithMock(cherrypy.server.httpserver.socket, 'getsockname')
+    cherrypy.server.httpserver.socket.getsockname().AndReturn(None)
+    cherrypy.server.httpserver.socket.getsockname().AndReturn(('', 55555))
+
+    self.mox.ReplayAll()
+
+    plugin = cherrypy_ext.PortFile(bus, portfile)
+    plugin.start()  # Signal server start; no socket binding yet.
+    with open(portfile) as f:
+      self.assertEqual('', f.read())
+    plugin.log('foo', 1)  # Emit a log signal; socket "bound" at this point.
+    with open(portfile) as f:
+      self.assertEqual('55555', f.read())
+
+    self.mox.VerifyAll()
+
+  def testZeroPortPatcherSuccess(self):
+    """Make sure that ZeroPatcher successfully patches CherryPy.
+
+    This merely ensures that the patcher applies cleanly to the CherryPy
+    version available to the test environment, giving us some assurance that
+    it's still compatible with the range of versions that we might be using it
+    with.  The actual testing of the arbitrary port binding feature is covered
+    by integration tests.
+    """
+    self.assertIsNone(cherrypy_ext.ZeroPortPatcher.DoPatch(cherrypy))
+
+  def testZeroPortPatcherFailure(self):
+    """Make sure that ZeroPatcher fails with an incompatible CherryPy.
+
+    This ensures that the patcher fails when applied to a CherryPy version that
+    does not have the desired properties.
+    """
+    module = cherrypy.process.servers
+    func_name = 'wait_for_free_port'
+    orig_func = getattr(module, func_name, None)
+    self.assertTrue(orig_func)
+    delattr(module, func_name)
+    self.assertRaises(AttributeError, cherrypy_ext.ZeroPortPatcher.DoPatch,
+                      cherrypy)
+    setattr(module, func_name, orig_func)
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/devserver_integration_test.py b/devserver_integration_test.py
index 3dbddb6..755f2ce 100755
--- a/devserver_integration_test.py
+++ b/devserver_integration_test.py
@@ -26,6 +26,7 @@
 import psutil
 import shutil
 import signal
+import socket
 import subprocess
 import tempfile
 import time
@@ -66,6 +67,7 @@
 
 DEVSERVER_START_TIMEOUT = 15
 DEVSERVER_START_SLEEP = 1
+MAX_START_ATTEMPTS = 5
 
 
 class DevserverFailedToStart(Exception):
@@ -286,6 +288,32 @@
     self._StartServer()
 
 
+class DevserverStartTests(DevserverTestBase):
+  """Test that devserver starts up correctly."""
+
+  def testStartAnyPort(self):
+    """Starts the devserver, have it bind to an arbitrary available port."""
+    self._StartServer()
+
+  def testStartSpecificPort(self):
+    """Starts the devserver with a specific port."""
+    for _ in range(MAX_START_ATTEMPTS):
+      # This is a cheap hack to find an arbitrary unused port: we open a socket
+      # and bind it to port zero, then pull out the actual port number and
+      # close the socket. In all likelihood, this will leave us with an
+      # available port number that we can use for starting the devserver.
+      # However, this heuristic is susceptible to race conditions, hence the
+      # retry loop.
+      s = socket.socket()
+      s.bind(('', 0))
+      # s.getsockname() is definitely callable.
+      # pylint: disable=E1102
+      _, port = s.getsockname()
+      s.close()
+
+      self._StartServer(port=port)
+
+
 class DevserverBasicTests(AutoStartDevserverTestBase):
   """Short running tests for the devserver (no remote deps).