Skip to content

Commit 7c267ff

Browse files
committed
Add MacBook LAN-sender CI job and tests
Add a new GitHub Actions job (macbook-lan-sender) to smoke-test the MacBook one-click stack using deploy/docker-compose.macbook-base.yml. The job loads built Docker images, pulls base images, sets up Python, brings up the MacBook stack, starts lan_sender inside the telemetry container, runs pytest against a new test file, and collects/logs artifacts on failure. Also add tests/universal-telemetry-software/tests/test_macbook_base_lan_sender.py which exercise container health, status page, PECAN, telemetry logs, WebSocket CAN messages, and health metrics. Update CI compose validation to include the macbook compose file and add the new job as a dependency for publish-telemetry.
1 parent 09327e7 commit 7c267ff

2 files changed

Lines changed: 201 additions & 1 deletion

File tree

.github/workflows/telemetry-ci.yml

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ jobs:
8383
docker compose -f deploy/docker-compose.yml config --quiet
8484
docker compose -f deploy/docker-compose.test.yml config --quiet
8585
docker compose -f deploy/docker-compose.rpi-base.yml config --quiet
86+
docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook config --quiet
8687
docker compose -f deploy/docker-compose.staging.yml config --quiet
8788
docker compose -f deploy/docker-compose.can-test.yml config --quiet
8889
echo "✓ All compose configs valid"
@@ -489,8 +490,87 @@ jobs:
489490
# lan_sender runs inside the container — docker compose down stops it automatically
490491
docker compose -f deploy/docker-compose.lan-sender-test.yml down -v
491492
493+
# ── MacBook one-click stack smoke test with LAN sender fake car ─────────────
494+
macbook-lan-sender:
495+
runs-on: ubuntu-latest
496+
needs: build
497+
498+
steps:
499+
- name: Checkout code
500+
uses: actions/checkout@v4
501+
502+
- name: Download Docker image artifacts
503+
uses: actions/download-artifact@v4
504+
with:
505+
name: docker-images
506+
path: /tmp
507+
508+
- name: Load and tag MacBook stack images
509+
run: |
510+
docker load -i /tmp/telemetry.tar
511+
docker load -i /tmp/pecan.tar
512+
docker tag universal-telemetry:test ghcr.io/western-formula-racing/data-acquisition/universal-telemetry:latest
513+
docker tag pecan:test ghcr.io/western-formula-racing/data-acquisition/pecan:latest
514+
515+
- name: Pull base images
516+
run: docker pull redis:8.2
517+
518+
- name: Set up Python
519+
uses: actions/setup-python@v5
520+
with:
521+
python-version: '3.12'
522+
523+
- name: Install Python test dependencies
524+
run: pip install pytest pytest-asyncio pytest-timeout websockets requests
525+
526+
- name: Start MacBook base stack
527+
working-directory: ./universal-telemetry-software
528+
run: |
529+
docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook down -v --remove-orphans || true
530+
docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook up -d
531+
echo "Waiting for telemetry, WS relay, and PECAN to boot..."
532+
sleep 30
533+
534+
- name: Check MacBook stack health
535+
working-directory: ./universal-telemetry-software
536+
run: |
537+
docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook ps
538+
docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook logs --tail 40
539+
540+
- name: Start lan_sender.py inside telemetry container
541+
run: |
542+
docker exec -d daq-telemetry python3 /app/src/lan_sender.py 127.0.0.1 5005
543+
echo "lan_sender started inside daq-telemetry"
544+
545+
- name: Run MacBook stack LAN sender tests
546+
working-directory: ./universal-telemetry-software
547+
run: python -m pytest tests/test_macbook_base_lan_sender.py -v -s --timeout=120
548+
549+
- name: Collect MacBook stack logs on failure
550+
if: failure()
551+
working-directory: ./universal-telemetry-software
552+
run: |
553+
mkdir -p test-logs
554+
docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook logs --no-color > test-logs/macbook-compose.log 2>&1
555+
docker logs daq-telemetry > test-logs/macbook-telemetry.log 2>&1 || true
556+
docker logs daq-pecan > test-logs/macbook-pecan.log 2>&1 || true
557+
docker logs daq-redis > test-logs/macbook-redis.log 2>&1 || true
558+
559+
- name: Upload MacBook stack logs
560+
if: failure()
561+
uses: actions/upload-artifact@v4
562+
with:
563+
name: macbook-lan-sender-test-logs
564+
path: universal-telemetry-software/test-logs/
565+
retention-days: 7
566+
567+
- name: Cleanup MacBook stack
568+
if: always()
569+
working-directory: ./universal-telemetry-software
570+
run: docker compose -f deploy/docker-compose.macbook-base.yml --env-file deploy/.env.macbook down -v
571+
492572
publish-telemetry:
493-
needs: [unit-tests, integration, vcan, lan-sender]
573+
needs: [unit-tests, integration, vcan, lan-sender, macbook-lan-sender]
494574
runs-on: ubuntu-latest
495575
if: github.event_name == 'workflow_dispatch' || github.event_name != 'pull_request'
496576
permissions:
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
Smoke-test the out-of-box MacBook base stack with lan_sender.py as the fake car.
3+
4+
This intentionally targets deploy/docker-compose.macbook-base.yml rather than
5+
the lean LAN-sender test compose. The MacBook stack exposes telemetry/status,
6+
PECAN, and the WS relay by default, but it does not expose Redis or TimescaleDB.
7+
"""
8+
import asyncio
9+
import time
10+
11+
import pytest
12+
import requests
13+
14+
from .test_helpers import DockerHelper, WebSocketHelper, check_http_endpoint, wait_for_service
15+
16+
17+
TELEMETRY_CONTAINER = "daq-telemetry"
18+
PECAN_CONTAINER = "daq-pecan"
19+
REDIS_CONTAINER = "daq-redis"
20+
21+
STATUS_URL = "http://localhost:8080"
22+
HEALTH_URL = f"{STATUS_URL}/health"
23+
PECAN_URL = "http://localhost:3000"
24+
WS_URL = "ws://localhost:9080"
25+
26+
27+
@pytest.fixture(scope="module")
28+
def docker():
29+
return DockerHelper()
30+
31+
32+
def _health():
33+
response = requests.get(HEALTH_URL, timeout=5)
34+
response.raise_for_status()
35+
return response.json()
36+
37+
38+
def _wait_for_health(predicate, timeout=15):
39+
deadline = time.time() + timeout
40+
last_payload = None
41+
while time.time() < deadline:
42+
last_payload = _health()
43+
if predicate(last_payload):
44+
return last_payload
45+
time.sleep(1)
46+
raise AssertionError(f"Health condition not met; last payload: {last_payload}")
47+
48+
49+
class TestMacBookBaseStack:
50+
def test_default_containers_are_running(self, docker):
51+
for container in (TELEMETRY_CONTAINER, PECAN_CONTAINER, REDIS_CONTAINER):
52+
assert docker.is_container_running(container), f"{container} is not running"
53+
54+
def test_status_page_is_accessible(self):
55+
assert wait_for_service(lambda: check_http_endpoint(STATUS_URL), timeout=10)
56+
57+
def test_pecan_is_accessible(self):
58+
response = requests.get(PECAN_URL, timeout=5)
59+
assert response.status_code == 200
60+
assert "<div id=\"root\"" in response.text
61+
62+
63+
class TestMacBookLanSenderFlow:
64+
def test_health_reflects_live_udp_stream(self):
65+
payload = _wait_for_health(
66+
lambda health: (
67+
health.get("car", {}).get("alivable") is True
68+
and health.get("components", {}).get("can_bus", {}).get("status") == "ok"
69+
and health.get("components", {}).get("udp_listener", {}).get("status") == "ok"
70+
),
71+
timeout=20,
72+
)
73+
74+
assert payload["car"]["seen_s_ago"] is not None
75+
assert payload["stats"].get("received", 0) >= 0
76+
77+
def test_telemetry_logs_show_lan_sender_packets(self, docker):
78+
logs = docker.get_container_logs(TELEMETRY_CONTAINER, tail=300)
79+
assert (
80+
"Initial sequence:" in logs
81+
or "ECU time sync:" in logs
82+
or "Received" in logs
83+
), "Telemetry logs do not show UDP receive activity"
84+
85+
@pytest.mark.asyncio
86+
async def test_websocket_receives_lan_sender_can_data(self):
87+
ws = WebSocketHelper(WS_URL)
88+
try:
89+
await ws.connect()
90+
msg = None
91+
deadline = asyncio.get_event_loop().time() + 15
92+
while asyncio.get_event_loop().time() < deadline:
93+
candidate = await ws.receive_message(timeout=2)
94+
if isinstance(candidate, list) and candidate:
95+
msg = candidate
96+
break
97+
98+
assert msg is not None, "No CAN list message received via WebSocket"
99+
assert all(k in msg[0] for k in ("time", "canId", "data"))
100+
finally:
101+
await ws.close()
102+
103+
@pytest.mark.asyncio
104+
async def test_health_splits_internal_relay_and_external_client(self):
105+
ws = WebSocketHelper(WS_URL)
106+
try:
107+
await ws.connect()
108+
payload = _wait_for_health(
109+
lambda health: (
110+
(health.get("components", {}).get("websocket_bridge", {}).get("internal_clients") or 0) >= 1
111+
and (health.get("components", {}).get("websocket_bridge", {}).get("external_clients") or 0) >= 1
112+
),
113+
timeout=15,
114+
)
115+
116+
websocket = payload["components"]["websocket_bridge"]
117+
assert websocket["detail"] is None
118+
assert websocket["clients"] >= websocket["internal_clients"] + websocket["external_clients"]
119+
finally:
120+
await ws.close()

0 commit comments

Comments
 (0)