diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39a3052d..a69a1192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,13 @@ on: branches: ["**"] pull_request: branches: ["**"] - workflow_dispatch: {} + workflow_dispatch: + inputs: + run_hardware_e2e: + description: "Run hardware E2E (backend websocket + headless UI)" + required: false + type: boolean + default: false defaults: run: @@ -49,18 +55,33 @@ jobs: libopencv-dev libexiv2-dev libwebsocketpp-dev \ rapidjson-dev libssl-dev libgps-dev libboost-system-dev \ python3-lxml libxml2-dev libxslt1-dev \ - lcov + lcov \ + python3-websockets sudo -H python3 -m pip install --no-cache-dir catkin-lint flake8 coverage pytest if [ ! -f /etc/ros/rosdep/sources.list.d/20-default.list ]; then sudo rosdep init fi rosdep update + - name: Pre-clean workspace (sudo) + run: | + set -eo pipefail + sudo rm -rf "$GITHUB_WORKSPACE"/* + sudo mkdir -p "$GITHUB_WORKSPACE" + sudo chown -R "$USER:$USER" "$GITHUB_WORKSPACE" + - name: Checkout repository uses: actions/checkout@v4 with: submodules: recursive + - name: Clean Catkin workspace (self-hosted) + run: | + set -eo pipefail + if [ -d "$CATKIN_WS" ]; then + sudo rm -rf "$CATKIN_WS/build" "$CATKIN_WS/devel" "$CATKIN_WS/logs" + fi + - name: Resolve ROS package dependencies run: | set -eo pipefail @@ -120,6 +141,49 @@ jobs: # Report results but do not fail the whole job on first adoption catkin_test_results build/test_results || true + - name: Setup Playwright (hardware E2E) + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.run_hardware_e2e == 'true') || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) }} + run: | + set -eo pipefail + cd test/e2e + npm install --no-package-lock + npx playwright install --with-deps chromium + + - name: Run hardware E2E (backend + headless UI) + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.run_hardware_e2e == 'true') || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) }} + env: + # Hardware-specific: set this to the USB serial adapter used for injecting fake NMEA DBT strings. + # Must NOT point at /dev/sonar. + DIAGNOSTICS_FAKE_SERIAL_PORT: "/dev/ttyUSB1" + # Relaxed defaults; adjust if the bench needs longer to come up. + POSEIDON_E2E_WAIT_SECONDS: "120" + DIAGNOSTICS_WS_LAUNCH_TIMEOUT: "120" + POSEIDON_E2E_TIMEOUT_MS: "120000" + POSEIDON_E2E_LAUNCH_AS_ROOT: "1" + # Fail early if these expected rows disappear. + POSEIDON_E2E_REQUIRED_DIAGNOSTICS: "GNSS Fix,IMU Communication,Sonar Communication" + # Avoid port/node conflicts with a system-installed Poseidon service on the bench. + POSEIDON_E2E_EXCLUSIVE: "1" + # Make comm diagnostics more tolerant on benches where rates can be lower than nominal. + POSEIDON_DIAG_IMU_HZ: "5" + POSEIDON_DIAG_IMU_WINDOW: "2.0" + POSEIDON_DIAG_IMU_RATIO: "0.5" + POSEIDON_DIAG_SONAR_HZ: "1" + POSEIDON_DIAG_SONAR_WINDOW: "5.0" + POSEIDON_DIAG_SONAR_RATIO: "0.2" + POSEIDON_E2E_DIAGNOSTICS_REFRESH_DELAY_MS: "7000" + run: | + set -eo pipefail + bash test/e2e/run_poseidon_e2e.sh + + - name: Upload hardware E2E artifacts + if: ${{ always() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.run_hardware_e2e == 'true') || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'))) }} + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts + path: test/e2e/artifacts + if-no-files-found: ignore + - name: C++ coverage report (gcovr) run: | set -eo pipefail diff --git a/agents.md b/agents.md index 5ea89735..8ecd3e87 100644 --- a/agents.md +++ b/agents.md @@ -6,6 +6,7 @@ This guide targets contributors (humans or agents) working in Poseidon. It cover - Documentation policy: English only across the repository. - Track ROS1 changes in `version.md`; keep it up to date and group entries by date. - When editing `version.md`, add new notes under the heading matching the current date (do not add entries under older dates). +- Keep contributor guidance current; update `agents.md`, `version.md`, and `manifest.md` when conventions or structure change. - Make minimal, safe, targeted changes. - Respect Catkin structure and separation of C++/Python/launch. - Keep compatibility across embedded targets (RPi/RockPi) and VM. @@ -93,6 +94,7 @@ This guide targets contributors (humans or agents) working in Poseidon. It cover - [ ] Inline code comments/docstrings remain accurate - [ ] Related docs in `doc/` updated when behavior/usage changes - [ ] `version.md` updated with notable changes +- [ ] `agents.md` updated when contributor or automation guidance changes - [ ] No secrets or non-portable hardcoded paths - [ ] `manifest.md` updated for structural changes - [ ] Run `clang-format` and `clang-tidy` for C++ changes (style/diagnostics) diff --git a/launchROSService.sh b/launchROSService.sh index 7822e64a..711f7503 100755 --- a/launchROSService.sh +++ b/launchROSService.sh @@ -2,8 +2,14 @@ # Used to call a launch file as a service on boot +set -eo pipefail + +# Allow overriding the Poseidon root (useful for CI where the repo is checked out elsewhere). +POSEIDON_ROOT="${POSEIDON_ROOT:-"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"}" +export POSEIDON_ROOT + source /opt/ros/noetic/setup.bash -source /opt/Poseidon/src/workspace/devel/setup.bash +source "$POSEIDON_ROOT/src/workspace/devel/setup.bash" ######################## # Echoboat # @@ -104,7 +110,15 @@ source /opt/Poseidon/src/workspace/devel/setup.bash # Dummy simulator #roslaunch /opt/Poseidon/src/workspace/launch/Simulator/dummy_simulator.launch -roslaunch /opt/Poseidon/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch time_now:=$(date +%Y.%m.%d_%H%M%S) +LOGGER_PATH="${POSEIDON_LOGGER_PATH:-"$POSEIDON_ROOT/www/webroot/record/"}" +CONFIG_PATH="${POSEIDON_CONFIG_PATH:-"$POSEIDON_ROOT/config.txt"}" +SONAR_DEVICE="${POSEIDON_SONAR_DEVICE:-"/dev/sonar"}" + +roslaunch "$POSEIDON_ROOT/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch" \ + time_now:=$(date +%Y.%m.%d_%H%M%S) \ + loggerPath:="$LOGGER_PATH" \ + configPath:="$CONFIG_PATH" \ + sonarDevice:="$SONAR_DEVICE" ######################## # Configuration # diff --git a/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch b/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch index a449d77b..edfcc596 100644 --- a/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch +++ b/src/workspace/launch/Hydrobox/hydrobox_rpi_nmeadevice_ZED-F9P_bno055.launch @@ -2,14 +2,21 @@ - - - + + + - - + + + + + + + + + @@ -28,8 +35,6 @@ - - diff --git a/src/workspace/src/diagnostics/CMakeLists.txt b/src/workspace/src/diagnostics/CMakeLists.txt index f2f89a18..d05d1c84 100644 --- a/src/workspace/src/diagnostics/CMakeLists.txt +++ b/src/workspace/src/diagnostics/CMakeLists.txt @@ -19,5 +19,6 @@ if(CATKIN_ENABLE_TESTING) ) catkin_add_nosetests(tests/test_diagnostics_unit.py) + catkin_add_nosetests(tests/test_launchrosservice_websocket.py) add_rostest(tests/diagnostics_websocket.test) endif() diff --git a/src/workspace/src/diagnostics/package.xml b/src/workspace/src/diagnostics/package.xml index 5404736f..5a88cdd7 100644 --- a/src/workspace/src/diagnostics/package.xml +++ b/src/workspace/src/diagnostics/package.xml @@ -13,7 +13,9 @@ rospy diagnostic_msgs std_msgs + python3-websockets rostest + python3-websockets diff --git a/src/workspace/src/diagnostics/scripts/diagnostics_websocket.py b/src/workspace/src/diagnostics/scripts/diagnostics_websocket.py index cd2040c8..b777b6ae 100755 --- a/src/workspace/src/diagnostics/scripts/diagnostics_websocket.py +++ b/src/workspace/src/diagnostics/scripts/diagnostics_websocket.py @@ -125,15 +125,37 @@ async def websocket_handler(self, websocket, path): def start_server(self, port=9099): """Lauch WebSocket server in asyncio loop.""" self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) + try: + asyncio.set_event_loop(self.loop) + except AssertionError: + # Tests may inject a dummy loop that is not an AbstractEventLoop; skip binding in that case. + pass + + async def _start_server(): + # Newer versions of `websockets` require `serve()` to be called from a running loop. + return await websockets.serve(self.websocket_handler, "0.0.0.0", port) + + try: + self.server = self.loop.run_until_complete(_start_server()) + except Exception as exc: + ws_version = getattr(websockets, "__version__", "unknown") + rospy.logerr( + f"Failed to start DiagnosticsServer WebSocket on port {port} " + f"(websockets={ws_version}, python={sys.version.split()[0]}): {exc}" + ) + return - self.server = websockets.serve(self.websocket_handler, "0.0.0.0", port) - self.loop.run_until_complete(self.server) rospy.loginfo(f"DiagnosticsServer WebSocket listen to port {port}") self.loop.run_forever() def stop_server(self): """Stop WebSocket server.""" + if self.server: + try: + self.server.close() + except Exception: + pass + if self.loop and self.loop.is_running(): rospy.loginfo("Stopping WebSocket server...") self.loop.call_soon_threadsafe(self.loop.stop) @@ -149,6 +171,18 @@ def main(): rospy.init_node('diagnostics') server = DiagnosticsServer() + def _env_int(name: str, default: int) -> int: + try: + return int(os.environ.get(name, str(default))) + except Exception: + return default + + def _env_float(name: str, default: float) -> float: + try: + return float(os.environ.get(name, str(default))) + except Exception: + return default + # Connectivity first to gate DNS/API tests server.add_test(InternetConnectivityDiagnostic(url="http://example.com", timeout_s=3.0)) server.add_test(DnsResolutionDiagnostic(hostname="google.com")) @@ -168,18 +202,18 @@ def main(): )) server.add_test(ImuCommunicationDiagnostic( name="IMU Communication", - message_frequency=100, - topic="/imu/data", - timeout_s=1.0, - expected_ratio=0.8 + message_frequency=_env_int("POSEIDON_DIAG_IMU_HZ", 100), + topic=os.environ.get("POSEIDON_DIAG_IMU_TOPIC", "/imu/data"), + timeout_s=_env_float("POSEIDON_DIAG_IMU_WINDOW", 1.0), + expected_ratio=_env_float("POSEIDON_DIAG_IMU_RATIO", 0.8), )) server.add_test(SerialNumberDiagnostic(name="Serial Number Pattern Validation")) server.add_test(SonarCommunicationDiagnostic( name="Sonar Communication", - message_frequency=1, # car /depth ≈ 1 Hz - topic="/depth", - timeout_s=1.5, - expected_ratio=0.8 + message_frequency=_env_int("POSEIDON_DIAG_SONAR_HZ", 1), # /depth is often ~1 Hz + topic=os.environ.get("POSEIDON_DIAG_SONAR_TOPIC", "/depth"), + timeout_s=_env_float("POSEIDON_DIAG_SONAR_WINDOW", 1.5), + expected_ratio=_env_float("POSEIDON_DIAG_SONAR_RATIO", 0.8), )) diff --git a/src/workspace/src/diagnostics/tests/test_launchrosservice_websocket.py b/src/workspace/src/diagnostics/tests/test_launchrosservice_websocket.py new file mode 100644 index 00000000..87ae21f8 --- /dev/null +++ b/src/workspace/src/diagnostics/tests/test_launchrosservice_websocket.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 + +import asyncio +import json +import os +import signal +import subprocess +import time +import unittest +from pathlib import Path +import threading + +import websockets + + +DEFAULT_PORT = int(os.environ.get("DIAGNOSTICS_WS_PORT", "9099")) +LAUNCH_TIMEOUT = int(os.environ.get("DIAGNOSTICS_WS_LAUNCH_TIMEOUT", "60")) +RESPONSE_TIMEOUT = int(os.environ.get("DIAGNOSTICS_WS_RESPONSE_TIMEOUT", "45")) +FAKE_SERIAL_PORT = os.environ.get("DIAGNOSTICS_FAKE_SERIAL_PORT", "/dev/ttyUSB1") +E2E_ENABLED = os.environ.get("POSEIDON_E2E", "0") == "1" +REUSE_RUNNING = os.environ.get("POSEIDON_E2E_REUSE_RUNNING", "0") == "1" +LAUNCH_AS_ROOT = os.environ.get("POSEIDON_E2E_LAUNCH_AS_ROOT", "1") == "1" + + +def find_launch_script(): + """Look up launchROSService.sh starting from this test directory and walking upwards.""" + for parent in Path(__file__).resolve().parents: + candidate = parent / "launchROSService.sh" + if candidate.is_file(): + return candidate + return None + + +async def wait_for_websocket(port, timeout): + """Wait until the diagnostics websocket accepts connections.""" + url = f"ws://localhost:{port}" + deadline = time.time() + timeout + last_error = None + + while time.time() < deadline: + try: + async with websockets.connect(url): + return + except Exception as exc: # noqa: BLE001 - best-effort connection loop + last_error = exc + await asyncio.sleep(1.0) + + raise TimeoutError(f"WebSocket server not reachable at {url}: {last_error}") + + +async def request_diagnostics(port): + """Send the updateDiagnostic command and return the parsed payload.""" + url = f"ws://localhost:{port}" + async with websockets.connect(url) as ws: + await ws.send(json.dumps({"command": "updateDiagnostic"})) + message = await asyncio.wait_for(ws.recv(), timeout=RESPONSE_TIMEOUT) + return json.loads(message) + + +class LaunchRosServiceWebsocketTest(unittest.TestCase): + """Integration test: start launchROSService.sh and verify diagnostics websocket replies.""" + + process = None + launch_script = None + _stdout_handle = None + _stderr_handle = None + + @classmethod + def setUpClass(cls): + if not E2E_ENABLED: + raise unittest.SkipTest("POSEIDON_E2E is not enabled; skipping hardware E2E test.") + + # Allow overriding the script location for installed vs. source trees. + env_override = os.environ.get("POSEIDON_LAUNCH_SCRIPT") + if env_override: + cls.launch_script = Path(env_override) + else: + cls.launch_script = find_launch_script() + + if not REUSE_RUNNING: + if not cls.launch_script or not cls.launch_script.is_file(): + raise unittest.SkipTest("launchROSService.sh not found; cannot run integration test.") + + cls.fake_serial_stop = cls._maybe_start_fake_serial_writer() + + if not REUSE_RUNNING: + if LAUNCH_AS_ROOT and os.geteuid() != 0: + try: + subprocess.run( + ["sudo", "-n", "true"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + "POSEIDON_E2E_LAUNCH_AS_ROOT=1 requires passwordless sudo." + ) from exc + + stdout_path = os.environ.get("POSEIDON_E2E_STDOUT_PATH") + stderr_path = os.environ.get("POSEIDON_E2E_STDERR_PATH") + cls._stdout_handle = open(stdout_path, "ab") if stdout_path else None + cls._stderr_handle = open(stderr_path, "ab") if stderr_path else None + stdout_target = cls._stdout_handle if cls._stdout_handle else subprocess.DEVNULL + stderr_target = cls._stderr_handle if cls._stderr_handle else subprocess.DEVNULL + + cmd = ["bash", str(cls.launch_script)] + if LAUNCH_AS_ROOT and os.geteuid() != 0: + cmd = ["sudo", "-E", "bash", str(cls.launch_script)] + + cls.process = subprocess.Popen( + cmd, + stdout=stdout_target, + stderr=stderr_target, + preexec_fn=os.setsid, + ) + + try: + asyncio.run(wait_for_websocket(DEFAULT_PORT, LAUNCH_TIMEOUT)) + except Exception: + cls._terminate_process() + raise + + @classmethod + def tearDownClass(cls): + cls._terminate_process() + + @classmethod + def _terminate_process(cls): + if hasattr(cls, "fake_serial_stop") and cls.fake_serial_stop: + cls.fake_serial_stop.set() + + if cls.process and cls.process.poll() is None: + try: + os.killpg(os.getpgid(cls.process.pid), signal.SIGINT) + except ProcessLookupError: + return + + try: + cls.process.wait(timeout=15) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(cls.process.pid), signal.SIGTERM) + cls.process.wait(timeout=10) + + for handle_name in ("_stdout_handle", "_stderr_handle"): + handle = getattr(cls, handle_name, None) + if handle: + try: + handle.close() + finally: + setattr(cls, handle_name, None) + + @staticmethod + def _maybe_start_fake_serial_writer(): + """ + Some test benches need a NMEA0183 DBT feed at ~1 Hz on a USB serial port that is NOT /dev/sonar. + If the port exists and is writable, launch a background writer so sonar diagnostics can consume data. + """ + if not FAKE_SERIAL_PORT: + return None + + if FAKE_SERIAL_PORT == "/dev/sonar": + return None + + port_path = Path(FAKE_SERIAL_PORT) + if not port_path.exists(): + return None + + stop_event = threading.Event() + + def compute_checksum(payload: str) -> str: + checksum = 0 + for ch in payload: + checksum ^= ord(ch) + return f"{checksum:02X}" + + def writer(): + payload = "SDDBT,30.9,f,9.4,M,5.1,F" + sentence = f"${payload}*{compute_checksum(payload)}\r\n" + while not stop_event.is_set(): + try: + with open(port_path, "wb", buffering=0) as fd: + fd.write(sentence.encode("ascii")) + except Exception: + # Best effort: if the port disappears, stop quietly. + break + stop_event.wait(1.0) + + threading.Thread(target=writer, daemon=True).start() + return stop_event + + def test_diagnostics_websocket_returns_expected_entries(self): + payload = asyncio.run(request_diagnostics(DEFAULT_PORT)) + diagnostics = payload.get("diagnostics", []) + + self.assertTrue(diagnostics, "No diagnostics payload returned from websocket.") + + expected_names = { + "Internet Connectivity", + "DNS Resolution", + "API Connection", + "BinaryStreamGnss", + "Clock Diagnostic", + "GNSS Communication", + "GNSS Fix", + "IMU Calibrated", + "IMU Communication", + "Serial Number Pattern Validation", + "Sonar Communication", + } + + received_names = {entry.get("name") for entry in diagnostics} + + missing = expected_names - received_names + self.assertFalse(missing, f"Missing diagnostics entries: {sorted(missing)}") + + for entry in diagnostics: + self.assertIn("status", entry) + self.assertIn("name", entry) + self.assertIn("message", entry) + self.assertIsInstance(entry["status"], bool) + self.assertIsInstance(entry["name"], str) + self.assertIsInstance(entry["message"], str) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore new file mode 100644 index 00000000..e4e73c57 --- /dev/null +++ b/test/e2e/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +artifacts/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 00000000..16b090bb --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "poseidon-e2e", + "version": "0.1.0", + "private": true, + "scripts": { + "test:ui": "node ui_test.js" + }, + "devDependencies": { + "playwright": "^1.50.1" + } +} + diff --git a/test/e2e/run_poseidon_e2e.sh b/test/e2e/run_poseidon_e2e.sh new file mode 100755 index 00000000..392e6d26 --- /dev/null +++ b/test/e2e/run_poseidon_e2e.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +export POSEIDON_ROOT="${POSEIDON_ROOT:-${REPO_ROOT}}" +export POSEIDON_E2E_ARTIFACT_DIR="${POSEIDON_E2E_ARTIFACT_DIR:-${SCRIPT_DIR}/artifacts}" +export POSEIDON_HTTP_PORT="${POSEIDON_HTTP_PORT:-8080}" +export POSEIDON_E2E_BASE_URL="${POSEIDON_E2E_BASE_URL:-http://127.0.0.1:${POSEIDON_HTTP_PORT}}" + +export DIAGNOSTICS_WS_PORT="${DIAGNOSTICS_WS_PORT:-9099}" +export POSEIDON_TELEMETRY_WS_PORT="${POSEIDON_TELEMETRY_WS_PORT:-9002}" +export POSEIDON_E2E="1" +export POSEIDON_E2E_REUSE_RUNNING="${POSEIDON_E2E_REUSE_RUNNING:-0}" +export POSEIDON_E2E_EXCLUSIVE="${POSEIDON_E2E_EXCLUSIVE:-0}" +export POSEIDON_E2E_LAUNCH_AS_ROOT="${POSEIDON_E2E_LAUNCH_AS_ROOT:-1}" +export POSEIDON_E2E_REQUIRED_NODES="${POSEIDON_E2E_REQUIRED_NODES:-Diagnostics}" + +mkdir -p "${POSEIDON_E2E_ARTIFACT_DIR}" + +# Best-effort: make ROS tools (rostopic) available for debugging. +if [[ -f /opt/ros/noetic/setup.bash ]]; then + # shellcheck disable=SC1091 + source /opt/ros/noetic/setup.bash +fi +if [[ -f "${POSEIDON_ROOT}/src/workspace/devel/setup.bash" ]]; then + # shellcheck disable=SC1091 + source "${POSEIDON_ROOT}/src/workspace/devel/setup.bash" +fi + +if [[ -z "${POSEIDON_CONFIG_PATH:-}" ]]; then + export POSEIDON_CONFIG_PATH="${POSEIDON_ROOT}/config.txt" +fi + +if [[ -z "${POSEIDON_LOGGER_PATH:-}" ]]; then + export POSEIDON_LOGGER_PATH="$(mktemp -d -t poseidon-logger-XXXXXX)" +fi +mkdir -p "${POSEIDON_LOGGER_PATH}" + +# If a dedicated "fake sonar" serial adapter exists, use it as the sonar source for this run. +# This avoids touching /dev/sonar and makes /depth deterministic for E2E. +if [[ -z "${POSEIDON_SONAR_DEVICE:-}" ]]; then + if [[ -n "${DIAGNOSTICS_FAKE_SERIAL_PORT:-}" && -e "${DIAGNOSTICS_FAKE_SERIAL_PORT}" && "${DIAGNOSTICS_FAKE_SERIAL_PORT}" != "/dev/sonar" ]]; then + export POSEIDON_SONAR_DEVICE="${DIAGNOSTICS_FAKE_SERIAL_PORT}" + else + export POSEIDON_SONAR_DEVICE="/dev/sonar" + fi +fi + +HTTP_LOG="${POSEIDON_E2E_ARTIFACT_DIR}/http.log" +SERVICE_LOG="${POSEIDON_E2E_ARTIFACT_DIR}/launchROSService.log" +SERVICE_LAUNCHED_AS_ROOT="0" + +if [[ "$(id -u)" -ne 0 ]]; then + if [[ "${POSEIDON_E2E_LAUNCH_AS_ROOT}" != "1" ]]; then + echo "[!] E2E requires root for I2C/GPIO access; set POSEIDON_E2E_LAUNCH_AS_ROOT=1." + exit 1 + fi + if ! sudo -n true 2>/dev/null; then + echo "[!] POSEIDON_E2E_LAUNCH_AS_ROOT=1 requires passwordless sudo." + exit 1 + fi +fi + +cleanup() { + set +e + + if [[ -n "${HTTP_PID:-}" ]] && kill -0 "${HTTP_PID}" 2>/dev/null; then + kill "${HTTP_PID}" 2>/dev/null || true + wait "${HTTP_PID}" 2>/dev/null || true + fi + + if [[ -n "${NMEA_WRITER_PID:-}" ]] && kill -0 "${NMEA_WRITER_PID}" 2>/dev/null; then + kill "${NMEA_WRITER_PID}" 2>/dev/null || true + wait "${NMEA_WRITER_PID}" 2>/dev/null || true + fi + + if [[ -n "${SERVICE_PID:-}" ]] && kill -0 "${SERVICE_PID}" 2>/dev/null; then + if [[ "${SERVICE_LAUNCHED_AS_ROOT}" == "1" ]]; then + sudo kill -INT -- "-${SERVICE_PID}" 2>/dev/null || true + else + kill -INT -- "-${SERVICE_PID}" 2>/dev/null || true + fi + sleep 8 + if [[ "${SERVICE_LAUNCHED_AS_ROOT}" == "1" ]]; then + sudo kill -TERM -- "-${SERVICE_PID}" 2>/dev/null || true + else + kill -TERM -- "-${SERVICE_PID}" 2>/dev/null || true + fi + sleep 3 + if [[ "${SERVICE_LAUNCHED_AS_ROOT}" == "1" ]]; then + sudo kill -KILL -- "-${SERVICE_PID}" 2>/dev/null || true + else + kill -KILL -- "-${SERVICE_PID}" 2>/dev/null || true + fi + fi +} +trap cleanup EXIT INT TERM + +start_http_server() { + pushd "${POSEIDON_ROOT}/www/webroot" >/dev/null + python3 -m http.server "${POSEIDON_HTTP_PORT}" --bind 127.0.0.1 >"${HTTP_LOG}" 2>&1 & + HTTP_PID=$! + popd >/dev/null +} + +stop_existing_poseidon() { + if [[ "${POSEIDON_E2E_EXCLUSIVE}" == "1" ]]; then + # Best-effort cleanup of existing Poseidon instances on a bench. + if command -v systemctl >/dev/null 2>&1; then + sudo systemctl stop ros 2>/dev/null || true + fi + if command -v fuser >/dev/null 2>&1; then + sudo fuser -k 9002/tcp 9099/tcp 9003/tcp 9004/tcp 2>/dev/null || true + fi + fi +} + +start_poseidon_service() { + if [[ "${POSEIDON_E2E_REUSE_RUNNING}" != "1" ]]; then + if [[ "${POSEIDON_E2E_LAUNCH_AS_ROOT}" == "1" && "$(id -u)" -ne 0 ]]; then + setsid sudo -E bash "${POSEIDON_ROOT}/launchROSService.sh" >"${SERVICE_LOG}" 2>&1 & + SERVICE_LAUNCHED_AS_ROOT="1" + else + setsid bash "${POSEIDON_ROOT}/launchROSService.sh" >"${SERVICE_LOG}" 2>&1 & + fi + SERVICE_PID=$! + export POSEIDON_E2E_REUSE_RUNNING="1" + fi +} + +start_fake_nmea_writer() { + # Keep a fake NMEA DBT feed alive during both backend and UI phases (if the port exists). + if [[ -n "${DIAGNOSTICS_FAKE_SERIAL_PORT:-}" && -e "${DIAGNOSTICS_FAKE_SERIAL_PORT}" && "${DIAGNOSTICS_FAKE_SERIAL_PORT}" != "/dev/sonar" ]]; then + python3 - <<'PY' & +import os +import time +from pathlib import Path + +port = Path(os.environ["DIAGNOSTICS_FAKE_SERIAL_PORT"]) + +def checksum(payload: str) -> str: + cs = 0 + for ch in payload: + cs ^= ord(ch) + return f"{cs:02X}" + +payload = "SDDBT,30.9,f,9.4,M,5.1,F" +sentence = f"${payload}*{checksum(payload)}\r\n".encode("ascii") + +while True: + try: + with open(port, "wb", buffering=0) as fd: + fd.write(sentence) + except Exception: + pass + time.sleep(1.0) +PY + NMEA_WRITER_PID=$! + fi +} + +wait_for_ports() { + if ! python3 - <<'PY' +import os +import socket +import time + +host = "127.0.0.1" +ports = [ + int(os.environ.get("DIAGNOSTICS_WS_PORT", "9099")), + int(os.environ.get("POSEIDON_TELEMETRY_WS_PORT", "9002")), + int(os.environ.get("POSEIDON_HTTP_PORT", "8080")), +] +deadline = time.time() + int(os.environ.get("POSEIDON_E2E_WAIT_SECONDS", "90")) + +pending = set(ports) +last_err = None +while pending and time.time() < deadline: + for port in list(pending): + try: + with socket.create_connection((host, port), timeout=1.0): + pending.remove(port) + except OSError as exc: + last_err = exc + time.sleep(1.0) + +if pending: + raise SystemExit(f"Timed out waiting for ports: {sorted(pending)} (last error: {last_err})") +PY + then + echo "[!] Poseidon did not expose required ports in time." + if [[ -n "${SERVICE_PID:-}" ]]; then + echo "[!] launchROSService PID: ${SERVICE_PID}" + fi + echo "[!] Tail of ${SERVICE_LOG}:" + tail -n 200 "${SERVICE_LOG}" || true + exit 1 + fi +} + +wait_for_ros_nodes() { + if ! command -v rosnode >/dev/null 2>&1; then + echo "[!] rosnode not found; skipping ROS node wait." + return 0 + fi + + local deadline + deadline=$(( $(date +%s) + ${POSEIDON_E2E_WAIT_SECONDS:-90} )) + + local -a required_nodes=() + if [[ -n "${POSEIDON_E2E_REQUIRED_NODES}" ]]; then + IFS=',' read -r -a required_nodes <<< "${POSEIDON_E2E_REQUIRED_NODES}" + fi + + local trimmed + local -a normalized_required=() + for node in "${required_nodes[@]}"; do + trimmed="${node#"${node%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + [[ -z "${trimmed}" ]] && continue + if [[ "${trimmed}" != /* ]]; then + trimmed="/${trimmed}" + fi + normalized_required+=("${trimmed}") + done + + while [[ "$(date +%s)" -le "${deadline}" ]]; do + local nodes + nodes="$(timeout 3s rosnode list 2>/dev/null || true)" + if [[ -n "${nodes}" ]]; then + if [[ "${#normalized_required[@]}" -eq 0 ]]; then + return 0 + fi + + local missing=() + for req in "${normalized_required[@]}"; do + if ! printf '%s\n' "${nodes}" | grep -Fxq "${req}"; then + missing+=("${req}") + fi + done + + if [[ "${#missing[@]}" -eq 0 ]]; then + return 0 + fi + fi + sleep 2 + done + + echo "[!] Timed out waiting for ROS nodes: ${normalized_required[*]:-}" + if [[ -n "${SERVICE_PID:-}" ]]; then + echo "[!] launchROSService PID: ${SERVICE_PID}" + fi + echo "[!] Tail of ${SERVICE_LOG}:" + tail -n 200 "${SERVICE_LOG}" || true + exit 1 +} + +phase_start_ros() { + echo "[phase 1] start ROS and wait for nodes" + start_http_server + stop_existing_poseidon + start_poseidon_service + start_fake_nmea_writer + wait_for_ports + wait_for_ros_nodes +} + +phase_backend_test() { + echo "[phase 2] run backend websocket test" + python3 "${POSEIDON_ROOT}/src/workspace/src/diagnostics/tests/test_launchrosservice_websocket.py" +} + +phase_ui_test() { + echo "[phase 3] run UI test" + pushd "${SCRIPT_DIR}" >/dev/null + if [[ ! -d node_modules ]]; then + npm install --silent --no-package-lock + fi + set +e + node ui_test.js + UI_STATUS=$? + set -e + + if [[ "${UI_STATUS}" -ne 0 ]]; then + echo "[!] UI E2E failed with exit code ${UI_STATUS}" + echo "[!] Tail of ${SERVICE_LOG}:" + tail -n 200 "${SERVICE_LOG}" || true + + if command -v rostopic >/dev/null 2>&1; then + echo "[!] rostopic list (first 200):" + rostopic list 2>/dev/null | head -n 200 || true + echo "[!] rostopic hz /imu/data (5s):" + timeout 5s rostopic hz /imu/data || true + echo "[!] rostopic hz /depth (5s):" + timeout 5s rostopic hz /depth || true + else + echo "[!] rostopic not found; skipping ROS topic debug." + fi + + python3 - <<'PY' || true +import asyncio +import json +import os + +try: + import websockets +except Exception as exc: + raise SystemExit(f"websockets not available: {exc}") + +PORT = int(os.environ.get("DIAGNOSTICS_WS_PORT", "9099")) +URL = f"ws://127.0.0.1:{PORT}" + +async def main(): + async with websockets.connect(URL) as ws: + await ws.send(json.dumps({"command": "updateDiagnostic"})) + raw = await ws.recv() + msg = json.loads(raw) + diags = {d.get("name"): d for d in msg.get("diagnostics", [])} + for key in ("GNSS Fix", "IMU Communication", "Sonar Communication"): + d = diags.get(key) + if not d: + print(f"[diagnostics] missing: {key}") + continue + print(f"[diagnostics] {key}: status={d.get('status')} message={d.get('message')}") + +asyncio.run(main()) +PY + + exit "${UI_STATUS}" + fi + popd >/dev/null +} + +phase_start_ros +phase_backend_test +phase_ui_test diff --git a/test/e2e/ui_test.js b/test/e2e/ui_test.js new file mode 100644 index 00000000..fbf145a8 --- /dev/null +++ b/test/e2e/ui_test.js @@ -0,0 +1,234 @@ +const fs = require("fs"); +const path = require("path"); +const { chromium } = require("playwright"); + +const baseUrl = process.env.POSEIDON_E2E_BASE_URL || "http://127.0.0.1:8080"; +const artifactDir = + process.env.POSEIDON_E2E_ARTIFACT_DIR || path.join(__dirname, "artifacts"); +const timeoutMs = Number.parseInt( + process.env.POSEIDON_E2E_TIMEOUT_MS || "90000", + 10, +); +const refreshDelayMs = Number.parseInt( + process.env.POSEIDON_E2E_DIAGNOSTICS_REFRESH_DELAY_MS || "6000", + 10, +); +const requireTelemetry = + (process.env.POSEIDON_E2E_REQUIRE_TELEMETRY || "0") === "1"; + +const requiredDiagnostics = (process.env.POSEIDON_E2E_REQUIRED_DIAGNOSTICS || + "GNSS Fix,IMU Communication,Sonar Communication") + .split(",") + .map((name) => name.trim()) + .filter(Boolean); +const requireOk = + (process.env.POSEIDON_E2E_REQUIRE_DIAGNOSTICS_OK || "1") === "1"; + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +async function run() { + ensureDir(artifactDir); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + const pageErrors = []; + const consoleErrors = []; + const websocketActivity = []; + + page.on("pageerror", (err) => pageErrors.push(String(err))); + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + page.on("websocket", (ws) => { + const entry = { + url: ws.url(), + openedAt: Date.now(), + received: [], + sent: [], + errors: [], + }; + websocketActivity.push(entry); + + ws.on("framesent", (frame) => { + entry.sent.push(frame.payload); + if (entry.sent.length > 20) entry.sent.shift(); + }); + ws.on("framereceived", (frame) => { + entry.received.push(frame.payload); + if (entry.received.length > 20) entry.received.shift(); + }); + ws.on("socketerror", (err) => { + entry.errors.push(String(err)); + if (entry.errors.length > 20) entry.errors.shift(); + }); + }); + + try { + await page.goto(`${baseUrl}/diagnostics.html`, { + waitUntil: "domcontentloaded", + timeout: timeoutMs, + }); + + await page.waitForFunction( + () => { + const table = document.querySelector("#diagnosticsTable"); + if (!table) return false; + const rows = table.querySelectorAll("tr"); + return rows.length > 1; + }, + null, + { timeout: timeoutMs }, + ); + + const diagnosticsDeadline = Date.now() + timeoutMs; + let diagStatus = {}; + while (Date.now() < diagnosticsDeadline) { + // Nudge the page to refresh diagnostics; this triggers `updateDiagnostic` on the websocket. + const btn = await page.$("#diagnosticsButton"); + if (btn) { + await btn.click(); + } + + await page.waitForTimeout(refreshDelayMs); + + diagStatus = await page.evaluate(() => { + const table = document.querySelector("#diagnosticsTable"); + const rows = Array.from(table.querySelectorAll("tr")).slice(1); + const out = {}; + for (const row of rows) { + const cells = row.querySelectorAll("td"); + if (cells.length < 3) continue; + const status = (cells[0].textContent || "").trim(); + const name = (cells[1].textContent || "").trim(); + const info = (cells[2].textContent || "").trim(); + if (!name) continue; + out[name] = { + ok: status.includes("✅"), + status, + info, + }; + } + return out; + }); + + const missing = requiredDiagnostics.filter((name) => !diagStatus[name]); + const failing = requiredDiagnostics.filter((name) => { + if (!diagStatus[name]) return false; + if (diagStatus[name].ok) return false; + if (!requireOk) { + // Accept WARN-like cases where messages are flowing but the rate is below threshold. + // Fail only if it clearly indicates no messages. + const info = (diagStatus[name].info || "").toLowerCase(); + if (info.includes("no imu message received")) return true; + if (info.includes("no message received")) return true; + if (info.includes("0 msg")) return true; + return false; + } + return true; + }); + + if (missing.length === 0 && failing.length === 0) { + break; + } + } + + const missing = requiredDiagnostics.filter((name) => !diagStatus[name]); + if (missing.length) { + throw new Error( + `diagnostics.html missing diagnostic rows: ${missing.join(", ")}`, + ); + } + + const failing = requiredDiagnostics.filter( + (name) => { + if (!diagStatus[name]) return false; + if (diagStatus[name].ok) return false; + if (!requireOk) { + const info = (diagStatus[name].info || "").toLowerCase(); + if (info.includes("no imu message received")) return true; + if (info.includes("no message received")) return true; + if (info.includes("0 msg")) return true; + return false; + } + return true; + }, + ); + if (failing.length) { + const details = failing + .map((name) => `${name}=${diagStatus[name].status} (${diagStatus[name].info})`) + .join(", "); + throw new Error(`diagnostics.html diagnostics not OK: ${details}`); + } + + await page.waitForFunction( + () => { + const table = document.querySelector("#runningNodesTable"); + if (!table) return false; + const rows = table.querySelectorAll("tr"); + return rows.length > 1; + }, + null, + { timeout: timeoutMs }, + ); + + if (requireTelemetry) { + await page.goto(`${baseUrl}/index.html`, { + waitUntil: "domcontentloaded", + timeout: timeoutMs, + }); + + // Dashboard shows an overlay until at least one telemetry message is received. + await page.waitForFunction( + () => { + const el = document.querySelector("#overlay"); + return el && el.style && el.style.display === "none"; + }, + null, + { timeout: timeoutMs }, + ); + } + + if (pageErrors.length) { + throw new Error(`Page errors: ${pageErrors.join(" | ")}`); + } + if (consoleErrors.length) { + throw new Error(`Console errors: ${consoleErrors.join(" | ")}`); + } + } catch (err) { + const screenshotPath = path.join(artifactDir, "ui_failure.png"); + try { + await page.screenshot({ path: screenshotPath, fullPage: true }); + } catch { + // Best effort screenshot. + } + + console.error(String(err)); + if (pageErrors.length) console.error("pageerror:", pageErrors); + if (consoleErrors.length) console.error("console.error:", consoleErrors); + if (websocketActivity.length) { + const wsPath = path.join(artifactDir, "websockets.json"); + try { + fs.writeFileSync(wsPath, JSON.stringify(websocketActivity, null, 2)); + console.error(`websocket debug saved to: ${wsPath}`); + } catch { + // Best effort. + } + } else { + console.error("No websocket activity observed by Playwright."); + } + process.exitCode = 1; + } finally { + await browser.close(); + } +} + +run().catch((err) => { + console.error(String(err)); + process.exitCode = 1; +}); diff --git a/version.md b/version.md index 966eb3f4..965dd45c 100644 --- a/version.md +++ b/version.md @@ -1,5 +1,38 @@ # Version History (English) +## 2026-01-12 +- CI: clean Catkin build/devel/logs on self-hosted runners before builds to avoid `.built_by` permission errors. +- CI: pre-clean the GitHub Actions workspace with sudo before checkout to avoid permission errors during repository cleanup. + +## 2026-01-08 +- e2e: require root launch by default (`POSEIDON_E2E_LAUNCH_AS_ROOT=1`) and reuse the already-started service during the E2E run to avoid duplicate roslaunch. +- diagnostics/e2e: honor `POSEIDON_E2E_LAUNCH_AS_ROOT=1` when the websocket integration test launches `launchROSService.sh`. +- e2e: split the E2E runner into explicit phases (start ROS + wait for nodes, backend test, UI test) with optional required node gating (`POSEIDON_E2E_REQUIRED_NODES`). +- CI: set `POSEIDON_E2E_LAUNCH_AS_ROOT=1` for hardware E2E runs to ensure sensor access. + +## 2025-12-18 +- e2e: added Playwright-based headless UI smoke test (verifies `diagnostics.html` populates diagnostics + running nodes tables and required sensor diagnostics report OK; optional telemetry check on `index.html`). +- e2e: added unified runner script that starts Poseidon, serves `www/webroot`, waits for ports, runs backend websocket E2E + UI headless checks, and stores artifacts under `test/e2e/artifacts`. +- diagnostics/e2e: gated the launchROSService websocket test behind `POSEIDON_E2E=1` and added `POSEIDON_E2E_REUSE_RUNNING=1` to support CI benches that already run ROS. +- diagnostics: start the WebSocket server from inside the asyncio loop to be compatible with newer `websockets` versions (fixes port 9099 not opening on some systems). +- launch: made `launchROSService.sh` relocatable via `POSEIDON_ROOT` (no longer hard-coded to `/opt/Poseidon`). +- launch: avoid `set -u` in `launchROSService.sh` so sourcing `/opt/ros/noetic/setup.bash` does not fail when ROS scripts reference unset variables (fixes CI E2E startup). +- launch/e2e: pass `loggerPath` and `configPath` from `POSEIDON_LOGGER_PATH` / `POSEIDON_CONFIG_PATH` via `launchROSService.sh` (avoids invalid nested `$(optenv ...)` substitutions in roslaunch args). +- CI: added optional "hardware E2E" workflow steps (Playwright install, run E2E, upload artifacts) triggered on `workflow_dispatch` or pushes to `main/master`. +- diagnostics/e2e: make IMU/sonar communication thresholds configurable via env vars (`POSEIDON_DIAG_IMU_*`, `POSEIDON_DIAG_SONAR_*`) to reduce false negatives on slower benches. +- e2e: allow tuning diagnostics refresh polling delay via `POSEIDON_E2E_DIAGNOSTICS_REFRESH_DELAY_MS` for longer-running diagnostics windows. +- e2e: on UI failure, runner now dumps `roslaunch` tail + basic `rostopic`/diagnostics websocket debug into the job logs for faster triage. +- e2e: avoid `set -u` in the E2E runner when sourcing ROS setup scripts (prevents `ROS_DISTRO: unbound variable` on some environments). +- e2e: keep a DBT NMEA writer running for the whole E2E so `/depth` can be validated when `DIAGNOSTICS_FAKE_SERIAL_PORT` is available. +- e2e: optional exclusive mode (`POSEIDON_E2E_EXCLUSIVE=1`) stops the system `ros` service and frees websocket ports before starting Poseidon to avoid port conflicts on benches. +- launch: pass `sonarDevice` into the Hydrobox launch and map it to `/Sonar/device` so the sonar node can be pointed at a test serial adapter. + +## 2025-12-03 +- diagnostics: added end-to-end nosetest that launches `launchROSService.sh`, waits for the 9099 diagnostics websocket, sends `updateDiagnostic`, and validates the returned payload; optional DBT feed on `DIAGNOSTICS_FAKE_SERIAL_PORT` (default `/dev/ttyUSB1`) to drive sonar without touching `/dev/sonar`. +- diagnostics: declare `python3-websockets` as exec/test dependency so websocket clients/servers are available during integration runs. +- diagnostics: fixed indentation in the launchROSService websocket test teardown to avoid SyntaxErrors during nosetests. +- diagnostics: guarded `asyncio.set_event_loop` in `diagnostics_websocket.start_server` to prevent dummy-loop `AssertionError` in unit tests. + ## 2025-12-02 - Web UI: System Status now shows wlan0 state + SSID using sysfs/proc+iwgetid (no nmcli dependency in telemetry path). - hydroball_data_websocket: publish Wi‑Fi status/SSID in telemetry payload (`telemetry.wifi`), sourced from `/sys/class/net/wlan0/operstate` and `/proc/net/wireless`.