Move hardware-specific-tests to tests dir

This commit is contained in:
Julian Stirling 2025-12-18 17:21:17 +00:00
parent 4e043f3df2
commit ef9a88104b
13 changed files with 7 additions and 221 deletions

View file

@ -1,214 +0,0 @@
#! /usr/bin/env python3
"""Start a server subprocess for integration tests.
These tests are separated from unit tests to avoid inflating test coverage.
For now, this file should be run directly rather than through a test framework.
They are designed to run on CI and should work on Linux or WSL for local debugging.
"""
import os
import shutil
import subprocess
from time import sleep, time
from typing import Optional
from PIL import Image
import labthings_fastapi as lt
THIS_DIR: str = os.path.dirname(os.path.realpath(__file__))
WORKING_DIR: str = os.path.join(THIS_DIR, "working_dir")
REPO_DIR: str = os.path.dirname(THIS_DIR)
CONFIG_FILE: str = os.path.join(REPO_DIR, "ofm_config_simulation.json")
SERVER_CMD: list[str] = [
"openflexure-microscope-server",
"--fallback",
"-c",
CONFIG_FILE,
]
def main() -> None:
"""Set up the server, run basic checks, shutdown, check for graceful exit.
The basic checks include checks that:
- The server boots
- The server reports the expected IP and port
- A ThingClient can connect to the camera
- The client grab a frame from the stream, and it is the expected size
- A background process can subscribe to the stream
"""
set_up_working_dir()
os.chdir(WORKING_DIR)
server_process: Optional[subprocess.Popen] = None
subscriber_process: Optional[subprocess.Popen] = None
try:
print("Starting server")
server_process = start_server()
error_if_server_not_started(server_process, timeout=15)
test_client_connection()
# This also sleeps for 2s and checks it is still connected
subscriber_process = subscribe_to_mjpeg_stream()
server_process.terminate()
sleep(3)
check_for_graceful_shutdown(server_process)
finally:
# Ensure the server and subscriber processes are really dead
if subscriber_process is not None and subscriber_process.poll() is None:
subscriber_process.kill()
if server_process is not None and server_process.poll() is None:
server_process.kill()
raise RuntimeError("Server needed killing at end of test.")
def test_client_connection() -> None:
"""Check a ThingClient can interact with the simulation microscope camera."""
print("Connecting Python client to microscope, and capturing image")
cam_client = lt.ThingClient.from_url("http://localhost:5000/camera/")
img = Image.open(cam_client.grab_jpeg().open())
print(f"Successfully grabbed image of size {img.size}")
assert img.size == (820, 616)
print("Successfully grabbed image from camera mjpeg stream")
def subscribe_to_mjpeg_stream() -> subprocess.Popen:
"""Start a background process subscribed to the mjpeg stream.
:returns: The Popen object for the ongoing process.
:raises: RuntimeError if the stream is not still connected after 2s
"""
# Use nohup to stop process hanging up unexpectedly. Explicitly forward stream
# to /dev/null to keep curl connected.
curl_command = [
"nohup",
"curl",
"-s",
"http://localhost:5000/camera/mjpeg_stream",
">",
"/dev/null",
"2>&1",
]
process = subprocess.Popen(
curl_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
universal_newlines=True,
)
os.set_blocking(process.stdout.fileno(), False)
sleep(2)
if process.poll() is not None:
raise RuntimeError(
"MJPEG Subscriber process is not running. This likely means it could not"
" stay connected to the stream.\n"
)
return process
def set_up_working_dir() -> None:
"""If working dir exists, delete it and make a new one."""
if os.path.exists(WORKING_DIR):
shutil.rmtree(WORKING_DIR)
os.makedirs(WORKING_DIR)
def start_server() -> subprocess.Popen:
"""Start the server in a subprocess.
The server is started in a subprocess and all outputs are buffered.
:returns: Popen object for the ongoing process
"""
process = subprocess.Popen(
SERVER_CMD,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
universal_newlines=True,
)
os.set_blocking(process.stdout.fileno(), False)
return process
def error_if_server_not_started(
server_process: subprocess.Popen, timeout: float = 15.0
) -> None:
"""Check the server started up as expected.
Check the subprocess is running
Check the logs have no errors
Read the Logs to check uvicorn is running on http://127.0.0.1:5000
:param server_process: The Popen object for the the server process.
:raises RuntimeError: If the server is not running as expected.
"""
confirmed_uvicorn_is_running = False
t_start = time()
while not confirmed_uvicorn_is_running and time() - t_start < timeout:
sleep(0.2)
if stdout := read_process_buffers(server_process):
print("".join(stdout), end="")
if server_process.poll() is not None:
raise RuntimeError("Server process is not running!")
for line in stdout:
if line.startswith("ERROR:"):
raise RuntimeError(f"Server started up with error\n{line}")
if "Uvicorn running on http://127.0.0.1:5000" in line:
confirmed_uvicorn_is_running = True
if not confirmed_uvicorn_is_running:
raise RuntimeError("Cannot confirm Uvicorn is running on http://127.0.0.1:5000")
# If we reached here Everything is fine!
print("Server is running as expected\n\n")
def check_for_graceful_shutdown(server_process: subprocess.Popen) -> None:
"""Check the server shutdown gracefully.
Check the subprocess is not running
Check the logs have no errors
Read the Logs to check the shutdown was graceful, rather than killed on Uvicorn
timeout
:param server_process: The Popen object for the the server process.
:raises RuntimeError: If the server didn't shutdown gracefully.
"""
stdout = read_process_buffers(server_process)
print("".join(stdout))
print("\n\n")
for line in stdout:
if line.startswith("ERROR:"):
raise RuntimeError(f"Server encountered error\n{line}")
if "graceful shutdown exceeded" in line:
# This should be logged as an ERROR. This check is belts and braces.
raise RuntimeError("Server failed to shutdown gracefully.")
def read_process_buffers(process: subprocess.Popen) -> list[str]:
"""Return STDOUT from a process."""
stdout = []
while line := process.stdout.readline():
stdout.append(line)
return stdout
if __name__ == "__main__":
main()

View file

@ -52,7 +52,7 @@ def _get_hashes(include_coverage: bool = False) -> dict[str, str]:
def run_tests() -> None: def run_tests() -> None:
"""Run the picamera tests, zip the coverage database, hash relevant source files.""" """Run the picamera tests, zip the coverage database, hash relevant source files."""
subprocess.run(["pytest", "hardware-specific-tests"], check=True) subprocess.run(["pytest", "tests/hardware_specific_tests"], check=True)
shutil.copyfile(".coverage", COVERAGE_FILE) shutil.copyfile(".coverage", COVERAGE_FILE)
hash_dict = _get_hashes(include_coverage=True) hash_dict = _get_hashes(include_coverage=True)
with open(HASH_FILE, "w", encoding="utf-8") as json_file: with open(HASH_FILE, "w", encoding="utf-8") as json_file:

View file

@ -144,7 +144,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# All testing dirs # All testing dirs
"{tests,integration-tests,hardware-specific-tests}/**" = [ "{tests,integration-tests}/**" = [
"B018", # Complaining about useless attribute access in tests, but we need them to check errors are raised "B018", # Complaining about useless attribute access in tests, but we need them to check errors are raised
"ANN", # Tests are not typehinted for fixtures etc "ANN", # Tests are not typehinted for fixtures etc
"S101", # Allow asserts in tests "S101", # Allow asserts in tests

View file

@ -34,13 +34,13 @@ As before, the server must be stopped before running these tests.
The camera test are very slow as they run on hardware. They can be run with: The camera test are very slow as they run on hardware. They can be run with:
pytest hardware-specific-tests pytest tests/hardware_specific_tests
However, this will not archive the tests in the Git repository for reporting the coverage. For this, see the section above on reporting the coverage. However, this will not archive the tests in the Git repository for reporting the coverage. For this, see the section above on reporting the coverage.
When writing and debugging these unit tests, it is often best to run a specific test and to use the `-s` flag to see the print statements. It is also often useful to use `--pdb` to drop you into a python debug session on any failure. For example, you might run: When writing and debugging these unit tests, it is often best to run a specific test and to use the `-s` flag to see the print statements. It is also often useful to use `--pdb` to drop you into a python debug session on any failure. For example, you might run:
pytest hardware-specific-tests/picamera2/test_exposure_time_drift.py::test_exposure_time_saves_and_loads -s --pdb pytest tests/hardware_specific_tests/picamera2/test_exposure_time_drift.py::test_exposure_time_saves_and_loads -s --pdb
### CI explanation ### CI explanation

View file

@ -5,7 +5,7 @@ from typing import Optional
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2 from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
from ..tests.utilities.lt_test_utils import LabThingsTestEnv from ...shared_utils.lt_test_utils import LabThingsTestEnv
@contextmanager @contextmanager

View file

@ -1,6 +1,6 @@
"""The unit-test suite for the OpenFlexure Microscope Server. """The unit-test suite for the OpenFlexure Microscope Server.
This package contains all of the unit tests that can be run without specific This package contains all of the unit tests that can be run without specific
hardware. See also the `hardware-specific-tests` directory and the hardware. See also the `hardware_specific_tests` directory and the
`integration-tests` directory for more testing!. `integration-tests` directory for more testing!.
""" """

View file

@ -1,6 +1,6 @@
"""Tests for camera classes. These tests focus on checking the camera APIs are equivalent. """Tests for camera classes. These tests focus on checking the camera APIs are equivalent.
Tests for specific camera hardware are in the hardware-specific-tests directory. Tests Tests for specific camera hardware are in the hardware_specific_tests directory. Tests
on camera functionality using the simulation camera are in "test_camera". on camera functionality using the simulation camera are in "test_camera".
""" """