Refactor scouting application testing

When testing the scouting application, we need a couple of components to
work together: the webserver itself and the fake The Blue Alliance API
server. This patch consolidates the logic into a single library that
both `//scouting:scouting_test` and the `cli_test` can use.

A future patch will add a postgresql server to this library.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: I4d561731bcaf1ed5a943de1ba8fe406894cd6ef8
diff --git a/scouting/BUILD b/scouting/BUILD
index a769426..0c2c641 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -33,7 +33,7 @@
         "//scouting/www:index.html",
         "//scouting/www:zonejs_copy",
     ],
-    visibility = ["//scouting/deploy:__pkg__"],
+    visibility = ["//visibility:public"],
 )
 
 protractor_ts_test(
@@ -42,5 +42,5 @@
         ":scouting_test.ts",
     ],
     on_prepare = ":scouting_test.protractor.on-prepare.js",
-    server = ":scouting",
+    server = "//scouting/testing:scouting_test_servers",
 )
diff --git a/scouting/testing/BUILD b/scouting/testing/BUILD
new file mode 100644
index 0000000..43bbe06
--- /dev/null
+++ b/scouting/testing/BUILD
@@ -0,0 +1,12 @@
+py_binary(
+    name = "scouting_test_servers",
+    testonly = True,
+    srcs = [
+        "scouting_test_servers.py",
+    ],
+    data = [
+        "//scouting",
+        "//scouting/scraping:test_data",
+    ],
+    visibility = ["//visibility:public"],
+)
diff --git a/scouting/testing/scouting_test_servers.py b/scouting/testing/scouting_test_servers.py
new file mode 100644
index 0000000..3447815
--- /dev/null
+++ b/scouting/testing/scouting_test_servers.py
@@ -0,0 +1,108 @@
+"""This library is here to run the various servers involved in the scouting app.
+
+The servers are:
+ - The fake TBA server
+ - The actual web server
+"""
+
+import argparse
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import time
+from typing import List
+
+def wait_for_server(port: int):
+    """Waits for the server at the specified port to respond to TCP connections."""
+    while True:
+        try:
+            connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            connection.connect(("localhost", port))
+            connection.close()
+            break
+        except ConnectionRefusedError:
+            connection.close()
+            time.sleep(0.01)
+
+def create_tba_config(tmpdir: Path) -> Path:
+    # Configure the scouting webserver to scrape data from our fake TBA
+    # server.
+    config = tmpdir / "scouting_config.json"
+    config.write_text(json.dumps({
+        "api_key": "dummy_key_that_is_not_actually_used_in_this_test",
+        "base_url": "http://localhost:7000",
+    }))
+    return config
+
+def set_up_tba_api_dir(tmpdir: Path, year: int, event_code: str):
+    tba_api_dir = tmpdir / "api" / "v3" / "event" / f"{year}{event_code}"
+    tba_api_dir.mkdir(parents=True, exist_ok=True)
+    (tba_api_dir / "matches").write_text(
+        Path(f"scouting/scraping/test_data/{year}_{event_code}.json").read_text()
+    )
+
+class Runner:
+    """Helps manage the services we need for testing the scouting app."""
+
+    def start(self, port: int):
+        """Starts the services needed for testing the scouting app."""
+        self.tmpdir = Path(os.environ["TEST_TMPDIR"]) / "servers"
+        self.tmpdir.mkdir(exist_ok=True)
+
+        db_path = self.tmpdir / "scouting.db"
+        tba_config = create_tba_config(self.tmpdir)
+
+        self.webserver = subprocess.Popen([
+            "scouting/scouting",
+            f"--port={port}",
+            f"--database={db_path}",
+            f"--tba_config={tba_config}",
+        ])
+
+        # Create a fake TBA server to serve the static match list.
+        set_up_tba_api_dir(self.tmpdir, year=2016, event_code="nytr")
+        set_up_tba_api_dir(self.tmpdir, year=2020, event_code="fake")
+        self.fake_tba_api = subprocess.Popen(
+            ["python3", "-m", "http.server", "7000"],
+            cwd=self.tmpdir,
+        )
+
+        # Wait for the TBA server and the scouting webserver to start up.
+        wait_for_server(7000)
+        wait_for_server(port)
+
+    def stop(self):
+        """Stops the services needed for testing the scouting app."""
+        servers = (self.webserver, self.fake_tba_api)
+        for server in servers:
+            server.terminate()
+        for server in servers:
+            server.wait()
+
+        try:
+            shutil.rmtree(self.tmpdir)
+        except FileNotFoundError:
+            pass
+
+
+def main(argv: List[str]):
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, help="The port for the actual web server.")
+    args = parser.parse_args(argv[1:])
+
+    runner = Runner()
+    runner.start(args.port)
+
+    # Wait until we're asked to shut down via CTRL-C or SIGTERM.
+    signal.pause()
+
+    runner.stop()
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/scouting/webserver/requests/debug/cli/BUILD b/scouting/webserver/requests/debug/cli/BUILD
index 903f8c8..371f66e 100644
--- a/scouting/webserver/requests/debug/cli/BUILD
+++ b/scouting/webserver/requests/debug/cli/BUILD
@@ -30,7 +30,8 @@
     ],
     data = [
         ":cli",
-        "//scouting/scraping:test_data",
-        "//scouting/webserver",
+    ],
+    deps = [
+        "//scouting/testing:scouting_test_servers",
     ],
 )
diff --git a/scouting/webserver/requests/debug/cli/cli_test.py b/scouting/webserver/requests/debug/cli/cli_test.py
index 64d79a6..f4b82b4 100644
--- a/scouting/webserver/requests/debug/cli/cli_test.py
+++ b/scouting/webserver/requests/debug/cli/cli_test.py
@@ -7,11 +7,15 @@
 import shutil
 import socket
 import subprocess
+import sys
 import textwrap
 import time
 from typing import Any, Dict, List
 import unittest
 
+import scouting.testing.scouting_test_servers
+
+
 def write_json_request(content: Dict[str, Any]):
     """Writes a JSON file with the specified dict content."""
     json_path = Path(os.environ["TEST_TMPDIR"]) / "test.json"
@@ -31,72 +35,15 @@
         run_result.stderr.decode("utf-8"),
     )
 
-def wait_for_server(port: int):
-    """Waits for the server at the specified port to respond to TCP connections."""
-    while True:
-        try:
-            connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-            connection.connect(("localhost", port))
-            connection.close()
-            break
-        except ConnectionRefusedError:
-            connection.close()
-            time.sleep(0.01)
-
 
 class TestDebugCli(unittest.TestCase):
 
     def setUp(self):
-        tmpdir = Path(os.environ["TEST_TMPDIR"]) / "temp"
-        try:
-            shutil.rmtree(tmpdir)
-        except FileNotFoundError:
-            pass
-        os.mkdir(tmpdir)
-
-        # Copy the test data into place so that the final API call can be
-        # emulated.
-        self.set_up_tba_api_dir(tmpdir, year=2016, event_code="nytr")
-        self.set_up_tba_api_dir(tmpdir, year=2020, event_code="fake")
-
-        # Create a fake TBA server to serve the static match list.
-        self.fake_tba_api = subprocess.Popen(
-            ["python3", "-m", "http.server", "7000"],
-            cwd=tmpdir,
-        )
-
-        # Configure the scouting webserver to scrape data from our fake TBA
-        # server.
-        scouting_config = tmpdir / "scouting_config.json"
-        scouting_config.write_text(json.dumps({
-            "api_key": "dummy_key_that_is_not_actually_used_in_this_test",
-            "base_url": "http://localhost:7000",
-        }))
-
-        # Run the scouting webserver.
-        self.webserver = subprocess.Popen([
-            "scouting/webserver/webserver_/webserver",
-            "-port=8080",
-            "-database=%s/database.db" % tmpdir,
-            "-tba_config=%s/scouting_config.json" % tmpdir,
-        ])
-
-        # Wait for the servers to be reachable.
-        wait_for_server(7000)
-        wait_for_server(8080)
+        self.servers = scouting.testing.scouting_test_servers.Runner()
+        self.servers.start(8080)
 
     def tearDown(self):
-        self.fake_tba_api.terminate()
-        self.webserver.terminate()
-        self.fake_tba_api.wait()
-        self.webserver.wait()
-
-    def set_up_tba_api_dir(self, tmpdir, year, event_code):
-        tba_api_dir = tmpdir / "api" / "v3" / "event" / f"{year}{event_code}"
-        os.makedirs(tba_api_dir)
-        (tba_api_dir / "matches").write_text(
-            Path(f"scouting/scraping/test_data/{year}_{event_code}.json").read_text()
-        )
+        self.servers.stop()
 
     def refresh_match_list(self, year=2016, event_code="nytr"):
         """Triggers the webserver to fetch the match list."""