Files
pi-pm3/test_hardware_2026-03-03.py
michael a9acdb85ce Build optimization: pre-built PM3 binaries, ARM64 CI, base image caching
Replace PM3 compile-from-source in pi-gen with pre-built tarball extraction
(saves 43-58 min). Merge stagePM3 into stageDangerousPi as 02-pm3-install
substage, renumber all subsequent substages. Switch CI PM3 build to native
ARM64 runner (ubuntu-24.04-arm64) eliminating QEMU overhead. Add weekly
base-image workflow for pre-baking stages 0-2. Support PM3_TARBALL,
BASE_IMAGE, and APT_PROXY env vars in build-image.sh.

Also includes prior Phase 5 work: theme system, design system integration,
component update system, OS updates, CI build pipeline, and test results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:01:01 -08:00

1202 lines
49 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Dangerous Pi - Hardware Integration Test Suite
Date: 2026-03-03
Runs against a live Pi over WiFi, serial console, and Bluetooth.
Usage:
source .venv/bin/activate
python test_hardware_2026-03-03.py # Run all phases
python test_hardware_2026-03-03.py --phase 1 # Run specific phase
python test_hardware_2026-03-03.py --safe-only # Skip destructive tests
"""
import argparse
import asyncio
import json
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
import httpx
import serial
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PI_HOST = "dangerous-pi.local"
API_BASE = f"http://{PI_HOST}:8000/api"
WS_BASE = f"ws://{PI_HOST}:8000/ws"
HTTPS_BASE = f"https://{PI_HOST}"
HTTP_UI = f"http://{PI_HOST}"
SERIAL_PORT = "/dev/ttyUSB0"
SERIAL_BAUD = 115200
BLE_DEVICE_NAME = "DangerousPi"
AUTH_USER = "admin"
AUTH_PASSWORD = "changeme"
REQUEST_TIMEOUT = 10.0
PM3_CMD_TIMEOUT = 30.0
# ---------------------------------------------------------------------------
# Test result tracking
# ---------------------------------------------------------------------------
@dataclass
class TestResult:
phase: int
test_id: str
name: str
status: str # PASS, FAIL, SKIP, WARN
detail: str = ""
duration_ms: float = 0
data: dict = field(default_factory=dict)
results: list[TestResult] = []
def record(phase: int, test_id: str, name: str, status: str,
detail: str = "", duration_ms: float = 0, data: dict | None = None):
r = TestResult(phase, test_id, name, status, detail, duration_ms, data or {})
results.append(r)
icon = {"PASS": "\033[32m✅", "FAIL": "\033[31m❌",
"SKIP": "\033[33m⏭", "WARN": "\033[33m⚠"}[status]
print(f" {icon} {test_id} {name}: {detail}\033[0m")
return r
# ---------------------------------------------------------------------------
# Serial console helper
# ---------------------------------------------------------------------------
class SerialConsole:
def __init__(self, port=SERIAL_PORT, baud=SERIAL_BAUD):
self.port = port
self.baud = baud
self._ser = None
def open(self):
try:
self._ser = serial.Serial(self.port, self.baud, timeout=3)
# Flush any pending data
self._ser.reset_input_buffer()
return True
except Exception as e:
print(f" [serial] Cannot open {self.port}: {e}")
return False
def close(self):
if self._ser and self._ser.is_open:
self._ser.close()
def run_command(self, cmd: str, timeout: float = 10.0) -> str:
"""Send a command and read output. Expects a logged-in shell."""
if not self._ser or not self._ser.is_open:
return ""
# Send command
self._ser.write(f"{cmd}\n".encode())
self._ser.flush()
time.sleep(0.3)
# Read output until timeout
output = []
deadline = time.time() + timeout
while time.time() < deadline:
if self._ser.in_waiting:
chunk = self._ser.read(self._ser.in_waiting).decode("utf-8", errors="replace")
output.append(chunk)
time.sleep(0.1)
else:
time.sleep(0.2)
if not self._ser.in_waiting:
break
return "".join(output)
serial_console = SerialConsole()
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def timed_get(client: httpx.Client, url: str, **kwargs) -> tuple[httpx.Response | None, float]:
t0 = time.monotonic()
try:
r = client.get(url, timeout=kwargs.pop("timeout", REQUEST_TIMEOUT), **kwargs)
return r, (time.monotonic() - t0) * 1000
except Exception as e:
return None, (time.monotonic() - t0) * 1000
def timed_post(client: httpx.Client, url: str, **kwargs) -> tuple[httpx.Response | None, float]:
t0 = time.monotonic()
try:
r = client.post(url, timeout=kwargs.pop("timeout", REQUEST_TIMEOUT), **kwargs)
return r, (time.monotonic() - t0) * 1000
except Exception as e:
return None, (time.monotonic() - t0) * 1000
# ---------------------------------------------------------------------------
# Phase 1: Regression tests
# ---------------------------------------------------------------------------
def phase1_regression(client: httpx.Client):
print("\n\033[1m═══ Phase 1: Regression - Retest Jan 23 Issues ═══\033[0m")
# 1.1 WiFi Connect API - just check status, don't actually reconnect
r, ms = timed_get(client, f"{API_BASE}/wifi/status")
if r and r.status_code == 200:
data = r.json()
connected = any(i.get("connected") for i in data.get("interfaces", []))
if connected:
record(1, "1.1", "WiFi status reports connected", "PASS",
f"ssid={data.get('current_ssid')}, ip={data.get('current_ip')}", ms, data)
else:
record(1, "1.1", "WiFi status reports connected", "WARN",
"WiFi reports not connected despite being reachable", ms)
else:
record(1, "1.1", "WiFi status reports connected", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 1.2 UPS detection
r, ms = timed_get(client, f"{API_BASE}/system/ups/status")
if r and r.status_code == 200:
data = r.json()
if data.get("is_available"):
record(1, "1.2", "UPS detected at boot", "PASS",
f"battery={data.get('battery_percentage')}%, source={data.get('power_source')}", ms, data)
else:
record(1, "1.2", "UPS detected at boot", "FAIL",
f"UPS not available: {data.get('error_message', 'unknown')}", ms, data)
else:
record(1, "1.2", "UPS detected at boot", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 1.3 BLE advertising
r, ms = timed_get(client, f"{API_BASE}/system/ble/status")
if r and r.status_code == 200:
data = r.json()
if data.get("advertising"):
record(1, "1.3", "BLE advertising active", "PASS",
f"name={data.get('device_name')}", ms, data)
else:
record(1, "1.3", "BLE advertising active", "WARN",
f"advertising=false, available={data.get('available')}", ms, data)
else:
record(1, "1.3", "BLE advertising active", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 1.4 Pydantic warning (via serial) - only check logs since last boot
if serial_console._ser and serial_console._ser.is_open:
out = serial_console.run_command(
"journalctl -u dangerous-pi --no-pager -b 2>/dev/null | grep -i 'protected namespace' | tail -3"
)
if "protected namespace" in out.lower():
record(1, "1.4", "Pydantic namespace warning", "WARN",
"Warning still present in logs", 0)
else:
record(1, "1.4", "Pydantic namespace warning", "PASS",
"No warning found in current boot logs", 0)
else:
record(1, "1.4", "Pydantic namespace warning", "SKIP",
"Serial console not available", 0)
# ---------------------------------------------------------------------------
# Phase 2: Core health & system endpoints
# ---------------------------------------------------------------------------
def phase2_system(client: httpx.Client):
print("\n\033[1m═══ Phase 2: Core Health & System Endpoints ═══\033[0m")
# 2.1 Health
r, ms = timed_get(client, f"{API_BASE}/health")
if r and r.status_code == 200 and r.json().get("status") == "healthy":
record(2, "2.1", "Health check", "PASS", f"{ms:.0f}ms", ms, r.json())
else:
record(2, "2.1", "Health check", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.2 Ready
r, ms = timed_get(client, f"{API_BASE}/ready")
if r and r.status_code == 200:
record(2, "2.2", "Readiness check", "PASS", f"{ms:.0f}ms", ms, r.json())
else:
record(2, "2.2", "Readiness check", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.3 System info
r, ms = timed_get(client, f"{API_BASE}/system/info")
if r and r.status_code == 200:
data = r.json()
temp = data.get("cpu_temp", data.get("cpu", {}).get("temperature", 0))
has_fields = all(k in data for k in ["hostname", "cpu_temp", "memory_used", "disk_used"])
if has_fields and temp < 85:
record(2, "2.3", "System info", "PASS",
f"temp={temp}°C, mem={data.get('memory_used',0)//1024//1024}MB", ms, data)
else:
record(2, "2.3", "System info", "FAIL",
f"Missing fields or temp too high ({temp}°C)", ms, data)
else:
record(2, "2.3", "System info", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.4 System config
r, ms = timed_get(client, f"{API_BASE}/system/config")
if r and r.status_code == 200:
record(2, "2.4", "System config", "PASS", f"{ms:.0f}ms", ms)
else:
# Config endpoint might not exist, mark as WARN
status = "WARN" if r and r.status_code == 404 else "FAIL"
record(2, "2.4", "System config", status,
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.5 Pi model
r, ms = timed_get(client, f"{API_BASE}/system/pi/model")
if r and r.status_code == 200:
record(2, "2.5", "Pi model info", "PASS", f"{r.json()}", ms, r.json())
else:
status = "WARN" if r and r.status_code == 404 else "FAIL"
record(2, "2.5", "Pi model info", status,
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.6 CPU cores
r, ms = timed_get(client, f"{API_BASE}/system/cpu/cores")
if r and r.status_code == 200:
data = r.json()
record(2, "2.6", "CPU cores", "PASS",
f"total={data.get('total_cores')}, online={data.get('online_cores')}", ms, data)
else:
status = "WARN" if r and r.status_code == 404 else "FAIL"
record(2, "2.6", "CPU cores", status,
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.7 Power restrictions
r, ms = timed_get(client, f"{API_BASE}/system/power/restrictions")
if r and r.status_code == 200:
record(2, "2.7", "Power restrictions", "PASS", f"{ms:.0f}ms", ms, r.json())
else:
record(2, "2.7", "Power restrictions", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.8 Header widgets
r, ms = timed_get(client, f"{API_BASE}/system/widgets")
if r and r.status_code == 200:
data = r.json()
record(2, "2.8", "Header widgets", "PASS",
f"{len(data) if isinstance(data, list) else 'dict'} widgets", ms)
else:
status = "WARN" if r and r.status_code == 404 else "FAIL"
record(2, "2.8", "Header widgets", status,
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 2.9 Response time check
times = []
for _ in range(5):
_, ms = timed_get(client, f"{API_BASE}/health")
times.append(ms)
avg = sum(times) / len(times)
if avg < 100:
record(2, "2.9", "Response time < 100ms avg", "PASS",
f"avg={avg:.0f}ms, min={min(times):.0f}ms, max={max(times):.0f}ms", avg)
else:
record(2, "2.9", "Response time < 100ms avg", "WARN",
f"avg={avg:.0f}ms (target < 100ms)", avg)
# ---------------------------------------------------------------------------
# Phase 3: PM3 Hardware Integration
# ---------------------------------------------------------------------------
def phase3_pm3(client: httpx.Client):
print("\n\033[1m═══ Phase 3: PM3 Hardware Integration ═══\033[0m")
# 3.1 Device discovery
r, ms = timed_get(client, f"{API_BASE}/pm3/devices")
devices = []
device_id = None
if r and r.status_code == 200:
data = r.json()
devices = data if isinstance(data, list) else data.get("devices", [])
if devices:
device_id = devices[0].get("device_id")
record(3, "3.1", "Device discovery", "PASS",
f"{len(devices)} device(s), id={device_id}", ms)
else:
record(3, "3.1", "Device discovery", "FAIL", "No devices found", ms)
else:
record(3, "3.1", "Device discovery", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 3.2 Device status
if device_id:
r, ms = timed_get(client, f"{API_BASE}/pm3/devices/{device_id}/status")
if r and r.status_code == 200:
record(3, "3.2", "Device status", "PASS", f"{r.json()}", ms, r.json())
else:
record(3, "3.2", "Device status", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.2", "Device status", "SKIP", "No device_id", 0)
# 3.3 PM3 overall status
r, ms = timed_get(client, f"{API_BASE}/pm3/status")
if r and r.status_code == 200:
data = r.json()
record(3, "3.3", "PM3 overall status", "PASS",
f"connected={data.get('connected')}", ms, data)
else:
record(3, "3.3", "PM3 overall status", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 3.4 Create session
session_id = None
r, ms = timed_post(client, f"{API_BASE}/system/session/create",
json={"force_takeover": True})
if r and r.status_code == 200:
data = r.json()
session_id = data.get("session_id")
record(3, "3.4", "Create session", "PASS", f"id={session_id}", ms)
else:
record(3, "3.4", "Create session", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}: {r.text if r else ''}", ms)
# 3.5 hw version
if session_id:
payload = {"command": "hw version", "session_id": session_id}
if device_id:
payload["device_id"] = device_id
r, ms = timed_post(client, f"{API_BASE}/pm3/command", json=payload,
timeout=PM3_CMD_TIMEOUT)
if r and r.status_code == 200:
data = r.json()
output = data.get("output", "")
if "iceman" in output.lower() or "client" in output.lower():
record(3, "3.5", "Execute hw version", "PASS",
f"{ms:.0f}ms", ms, {"output_preview": output[:200]})
else:
record(3, "3.5", "Execute hw version", "WARN",
f"Unexpected output: {output[:100]}", ms)
else:
record(3, "3.5", "Execute hw version", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.5", "Execute hw version", "SKIP", "No session", 0)
# 3.6 hw status
if session_id:
payload = {"command": "hw status", "session_id": session_id}
if device_id:
payload["device_id"] = device_id
r, ms = timed_post(client, f"{API_BASE}/pm3/command", json=payload,
timeout=PM3_CMD_TIMEOUT)
if r and r.status_code == 200:
record(3, "3.6", "Execute hw status", "PASS", f"{ms:.0f}ms", ms)
else:
record(3, "3.6", "Execute hw status", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.6", "Execute hw status", "SKIP", "No session", 0)
# 3.7 hw tune (slow)
if session_id:
payload = {"command": "hw tune", "session_id": session_id}
if device_id:
payload["device_id"] = device_id
r, ms = timed_post(client, f"{API_BASE}/pm3/command", json=payload,
timeout=PM3_CMD_TIMEOUT)
if r and r.status_code == 200:
output = r.json().get("output", "")
has_tune = "v @" in output.lower() or "khz" in output.lower() or "mhz" in output.lower()
if has_tune:
record(3, "3.7", "Execute hw tune", "PASS", f"{ms:.0f}ms", ms)
else:
record(3, "3.7", "Execute hw tune", "WARN",
f"No tune data in output ({ms:.0f}ms)", ms)
else:
record(3, "3.7", "Execute hw tune", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.7", "Execute hw tune", "SKIP", "No session", 0)
# 3.8 HF search
if session_id:
payload = {"command": "hf search", "session_id": session_id}
if device_id:
payload["device_id"] = device_id
r, ms = timed_post(client, f"{API_BASE}/pm3/command", json=payload,
timeout=PM3_CMD_TIMEOUT)
if r and r.status_code == 200:
record(3, "3.8", "HF search", "PASS", f"{ms:.0f}ms", ms)
else:
record(3, "3.8", "HF search", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.8", "HF search", "SKIP", "No session", 0)
# 3.9 LF search
if session_id:
payload = {"command": "lf search", "session_id": session_id}
if device_id:
payload["device_id"] = device_id
r, ms = timed_post(client, f"{API_BASE}/pm3/command", json=payload,
timeout=PM3_CMD_TIMEOUT)
if r and r.status_code == 200:
record(3, "3.9", "LF search", "PASS", f"{ms:.0f}ms", ms)
else:
record(3, "3.9", "LF search", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.9", "LF search", "SKIP", "No session", 0)
# 3.10 LED identify
if device_id:
r, ms = timed_post(client, f"{API_BASE}/pm3/devices/{device_id}/identify")
if r and r.status_code == 200:
record(3, "3.10", "LED identify", "PASS", f"{ms:.0f}ms", ms)
else:
record(3, "3.10", "LED identify", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.10", "LED identify", "SKIP", "No device_id", 0)
# 3.11 Command history
r, ms = timed_get(client, f"{API_BASE}/pm3/commands/history")
if r and r.status_code == 200:
data = r.json()
count = len(data) if isinstance(data, list) else data.get("count", "?")
record(3, "3.11", "Command history", "PASS", f"{count} entries", ms)
else:
status = "WARN" if r and r.status_code == 404 else "FAIL"
record(3, "3.11", "Command history", status,
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 3.12 Session release
if session_id:
r, ms = timed_post(client, f"{API_BASE}/system/session/{session_id}/release")
if r and r.status_code == 200:
record(3, "3.12", "Session release", "PASS", f"{ms:.0f}ms", ms)
else:
record(3, "3.12", "Session release", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
else:
record(3, "3.12", "Session release", "SKIP", "No session", 0)
# 3.14 Command execution time (hw version should be < 1s)
hw_ver_results = [r for r in results if r.test_id == "3.5"]
if hw_ver_results and hw_ver_results[0].status == "PASS":
dur = hw_ver_results[0].duration_ms
if dur < 1000:
record(3, "3.14", "Command time < 1s", "PASS", f"{dur:.0f}ms", dur)
else:
record(3, "3.14", "Command time < 1s", "WARN",
f"{dur:.0f}ms (target < 1000ms)", dur)
else:
record(3, "3.14", "Command time < 1s", "SKIP", "hw version not tested", 0)
# ---------------------------------------------------------------------------
# Phase 4: WiFi Manager
# ---------------------------------------------------------------------------
def phase4_wifi(client: httpx.Client):
print("\n\033[1m═══ Phase 4: WiFi Manager ═══\033[0m")
# 4.1 WiFi status
r, ms = timed_get(client, f"{API_BASE}/wifi/status")
wifi_data = {}
if r and r.status_code == 200:
wifi_data = r.json()
has_fields = "mode" in wifi_data and "interfaces" in wifi_data
if has_fields:
record(4, "4.1", "WiFi status", "PASS",
f"mode={wifi_data.get('mode')}, ssid={wifi_data.get('current_ssid')}", ms, wifi_data)
else:
record(4, "4.1", "WiFi status", "WARN", "Missing expected fields", ms, wifi_data)
else:
record(4, "4.1", "WiFi status", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 4.2 Interface detection
interfaces = wifi_data.get("interfaces", [])
wlan0 = next((i for i in interfaces if i.get("name") == "wlan0"), None)
if wlan0 and wlan0.get("is_up"):
record(4, "4.2", "Interface detection (wlan0)", "PASS",
f"mac={wlan0.get('mac')}, up=True", 0)
elif wlan0:
record(4, "4.2", "Interface detection (wlan0)", "WARN", "wlan0 found but not up", 0)
else:
record(4, "4.2", "Interface detection (wlan0)", "FAIL", "wlan0 not found", 0)
# 4.3 Network scan
r, ms = timed_get(client, f"{API_BASE}/wifi/scan", timeout=15.0)
if r and r.status_code == 200:
data = r.json()
networks = data if isinstance(data, list) else data.get("networks", [])
record(4, "4.3", "Network scan", "PASS", f"{len(networks)} networks found", ms)
else:
record(4, "4.3", "Network scan", "WARN",
f"HTTP {r.status_code if r else 'timeout'} (scan may require AP mode)", ms)
# 4.4 Saved networks
r, ms = timed_get(client, f"{API_BASE}/wifi/saved")
if r and r.status_code == 200:
data = r.json()
count = len(data) if isinstance(data, list) else data.get("count", "?")
record(4, "4.4", "Saved networks", "PASS", f"{count} saved", ms)
else:
record(4, "4.4", "Saved networks", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 4.5 Current connection
ip = wifi_data.get("current_ip", "")
if ip:
record(4, "4.5", "Current connection", "PASS", f"ip={ip}", 0)
else:
record(4, "4.5", "Current connection", "WARN", "No current IP reported", 0)
# 4.6 WiFi mode report
mode = wifi_data.get("mode", "unknown")
record(4, "4.6", "WiFi mode report", "PASS" if mode != "unknown" else "WARN",
f"mode={mode}", 0)
# ---------------------------------------------------------------------------
# Phase 5: HTTPS & SSL
# ---------------------------------------------------------------------------
def phase5_https(client: httpx.Client):
print("\n\033[1m═══ Phase 5: HTTPS & SSL (Phase 4 Feature) ═══\033[0m")
# 5.1 SSL info
r, ms = timed_get(client, f"{API_BASE}/system/ssl/info")
ssl_data = {}
if r and r.status_code == 200:
ssl_data = r.json()
record(5, "5.1", "SSL info endpoint", "PASS", f"{ms:.0f}ms", ms, ssl_data)
elif r and r.status_code == 404:
record(5, "5.1", "SSL info endpoint", "WARN", "Endpoint not found (404)", ms)
return # Skip remaining SSL tests
else:
record(5, "5.1", "SSL info endpoint", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
return
# 5.2 SSL status field
if "enabled" in ssl_data:
record(5, "5.2", "SSL enabled field present", "PASS",
f"enabled={ssl_data['enabled']}", 0)
else:
record(5, "5.2", "SSL enabled field present", "FAIL", "Missing 'enabled' field", 0)
# 5.3 Certificate details
sans = ssl_data.get("san", ssl_data.get("subject_alt_names", []))
if sans:
has_local = any("dangerous-pi.local" in str(s) for s in sans)
record(5, "5.3", "Certificate SANs", "PASS" if has_local else "WARN",
f"SANs={sans}", 0)
elif ssl_data.get("certificate_exists") is False:
record(5, "5.3", "Certificate SANs", "WARN", "No certificate generated yet", 0)
else:
record(5, "5.3", "Certificate SANs", "WARN", "No SAN data in response", 0)
# 5.4 HTTPS access
try:
with httpx.Client(verify=False) as https_client:
r2, ms = timed_get(https_client, f"{HTTPS_BASE}/api/health")
if r2 and r2.status_code == 200:
record(5, "5.4", "HTTPS access", "PASS", f"{ms:.0f}ms", ms)
else:
record(5, "5.4", "HTTPS access", "WARN",
f"HTTPS not responding (may not be enabled): {r2.status_code if r2 else 'timeout'}", ms)
except Exception as e:
record(5, "5.4", "HTTPS access", "WARN", f"HTTPS not available: {e}", 0)
# 5.5 SSL toggle (read-only check - don't actually toggle)
record(5, "5.5", "SSL toggle endpoint", "SKIP",
"Skipped to avoid disrupting connection", 0)
# 5.6 Cert regeneration (read-only check)
record(5, "5.6", "Cert regeneration", "SKIP",
"Skipped to avoid disrupting HTTPS state", 0)
# 5.7 & 5.8 Serial checks for cert script and nginx
if serial_console._ser and serial_console._ser.is_open:
out = serial_console.run_command(
"test -x /opt/dangerous-pi/scripts/generate-ssl-cert.sh && echo SCRIPTOK || echo NOTFOUND"
)
if "SCRIPTOK" in out:
record(5, "5.7", "SSL cert script exists", "PASS",
"Script at /opt/dangerous-pi/scripts/generate-ssl-cert.sh", 0)
else:
record(5, "5.7", "SSL cert script exists", "WARN", "Script not at expected path", 0)
out = serial_console.run_command("ls /etc/nginx/sites-available/ 2>/dev/null | head -5")
if "dangerous" in out.lower() or "nginx" in out.lower():
record(5, "5.8", "nginx config files", "PASS", f"Found configs", 0)
else:
record(5, "5.8", "nginx config files", "WARN", "No dangerous-pi nginx config found", 0)
else:
record(5, "5.7", "SSL cert script exists", "SKIP", "Serial not available", 0)
record(5, "5.8", "nginx config files", "SKIP", "Serial not available", 0)
# ---------------------------------------------------------------------------
# Phase 6: Authentication & WebSocket
# ---------------------------------------------------------------------------
def phase6_auth(client: httpx.Client, safe_only: bool = False):
print("\n\033[1m═══ Phase 6: Authentication & WebSocket ═══\033[0m")
# 6.1 Auth status (should be disabled by default)
r, ms = timed_get(client, f"{API_BASE}/auth/status")
auth_enabled = False
if r and r.status_code == 200:
data = r.json()
auth_enabled = data.get("auth_enabled", False)
record(6, "6.1", "Auth status check", "PASS",
f"auth_enabled={auth_enabled}", ms, data)
else:
record(6, "6.1", "Auth status check", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
if safe_only:
record(6, "6.2", "Enable auth via serial", "SKIP", "Safe mode", 0)
record(6, "6.3", "Auth status (enabled)", "SKIP", "Safe mode", 0)
record(6, "6.4", "Unauthenticated request rejected", "SKIP", "Safe mode", 0)
record(6, "6.5", "Authenticated request", "SKIP", "Safe mode", 0)
record(6, "6.6", "Get WS token", "SKIP", "Safe mode", 0)
record(6, "6.7", "WebSocket no token rejected", "SKIP", "Safe mode", 0)
record(6, "6.8", "WebSocket with token", "SKIP", "Safe mode", 0)
record(6, "6.9", "Disable auth via serial", "SKIP", "Safe mode", 0)
elif not auth_enabled:
# Auth is disabled - test what we can without enabling
record(6, "6.2", "Enable auth via serial", "SKIP",
"Auth is disabled, skipping auth toggle tests (use --phase 6 to run interactively)", 0)
for tid in ["6.3", "6.4", "6.5", "6.6", "6.7", "6.8", "6.9"]:
record(6, tid, f"Auth test {tid}", "SKIP", "Auth disabled on Pi", 0)
# 6.10 WebSocket event stream (works regardless of auth)
try:
import websockets
import websockets.sync.client
ws_url = f"ws://{PI_HOST}:8000/ws/events"
try:
ws = websockets.sync.client.connect(ws_url, open_timeout=5)
# Wait for a message
try:
msg = ws.recv(timeout=15)
data = json.loads(msg) if isinstance(msg, str) else msg
record(6, "6.10", "WebSocket event stream", "PASS",
f"Received event: {str(data)[:100]}", 0, {"first_event": str(data)[:200]})
except TimeoutError:
record(6, "6.10", "WebSocket event stream", "WARN",
"Connected but no events received in 15s", 0)
finally:
ws.close()
except Exception as e:
record(6, "6.10", "WebSocket event stream", "FAIL",
f"Connection failed: {e}", 0)
except ImportError:
record(6, "6.10", "WebSocket event stream", "SKIP", "websockets not installed", 0)
# ---------------------------------------------------------------------------
# Phase 7: Plugin System & Hooks
# ---------------------------------------------------------------------------
def phase7_plugins(client: httpx.Client):
print("\n\033[1m═══ Phase 7: Plugin System & Hooks ═══\033[0m")
# 7.1 Plugin discovery
r, ms = timed_get(client, f"{API_BASE}/plugins/discover")
if r and r.status_code == 200:
data = r.json()
plugins = data.get("plugins", [])
has_hello = "hello_world" in str(plugins)
record(7, "7.1", "Plugin discovery", "PASS" if has_hello else "WARN",
f"Found: {plugins}", ms, data)
else:
record(7, "7.1", "Plugin discovery", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 7.2 Plugin list
r, ms = timed_get(client, f"{API_BASE}/plugins/list")
if r and r.status_code == 200:
data = r.json()
count = len(data) if isinstance(data, list) else "?"
record(7, "7.2", "Plugin list", "PASS", f"{count} plugin(s)", ms)
else:
record(7, "7.2", "Plugin list", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 7.3 Plugin info
r, ms = timed_get(client, f"{API_BASE}/plugins/hello_world")
if r and r.status_code == 200:
data = r.json()
record(7, "7.3", "Plugin info (hello_world)", "PASS",
f"version={data.get('metadata', {}).get('version')}", ms, data)
else:
record(7, "7.3", "Plugin info (hello_world)", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 7.4 Load plugin
r, ms = timed_post(client, f"{API_BASE}/plugins/load",
json={"plugin_id": "hello_world"})
if r and r.status_code == 200:
record(7, "7.4", "Load plugin", "PASS", f"{ms:.0f}ms", ms)
else:
record(7, "7.4", "Load plugin", "WARN",
f"HTTP {r.status_code if r else 'timeout'}: {r.text[:100] if r else ''}", ms)
# 7.5 Enable plugin
r, ms = timed_post(client, f"{API_BASE}/plugins/enable",
json={"plugin_id": "hello_world"})
if r and r.status_code == 200:
record(7, "7.5", "Enable plugin", "PASS", f"{ms:.0f}ms", ms)
else:
record(7, "7.5", "Enable plugin", "WARN",
f"HTTP {r.status_code if r else 'timeout'}: {r.text[:100] if r else ''}", ms)
# 7.6 Hook trigger - run a PM3 command and check if hook fires
# Create a session, run command, check logs
r_sess, _ = timed_post(client, f"{API_BASE}/system/session/create",
json={"force_takeover": True})
if r_sess and r_sess.status_code == 200:
sid = r_sess.json().get("session_id")
r_cmd, ms = timed_post(client, f"{API_BASE}/pm3/command",
json={"command": "hw version", "session_id": sid},
timeout=PM3_CMD_TIMEOUT)
if r_cmd and r_cmd.status_code == 200:
record(7, "7.6", "Hook trigger (pm3_command)", "PASS",
"PM3 command executed with plugin enabled", ms)
else:
record(7, "7.6", "Hook trigger (pm3_command)", "WARN",
f"Command failed: {r_cmd.status_code if r_cmd else 'timeout'}", ms)
# Release session
timed_post(client, f"{API_BASE}/system/session/{sid}/release")
else:
record(7, "7.6", "Hook trigger (pm3_command)", "SKIP", "Could not create session", 0)
# 7.7 Plugin widgets
r, ms = timed_get(client, f"{API_BASE}/system/widgets")
if r and r.status_code == 200:
record(7, "7.7", "Plugin widgets", "PASS", f"{ms:.0f}ms", ms)
else:
record(7, "7.7", "Plugin widgets", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 7.8 Disable plugin
r, ms = timed_post(client, f"{API_BASE}/plugins/disable",
json={"plugin_id": "hello_world"})
if r and r.status_code == 200:
record(7, "7.8", "Disable plugin", "PASS", f"{ms:.0f}ms", ms)
else:
record(7, "7.8", "Disable plugin", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 7.9 Unload plugin
r, ms = timed_post(client, f"{API_BASE}/plugins/unload",
json={"plugin_id": "hello_world"})
if r and r.status_code == 200:
record(7, "7.9", "Unload plugin", "PASS", f"{ms:.0f}ms", ms)
else:
record(7, "7.9", "Unload plugin", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 7.10 Invalid plugin
r, ms = timed_get(client, f"{API_BASE}/plugins/nonexistent_plugin_xyz")
if r and r.status_code == 404:
record(7, "7.10", "Invalid plugin returns 404", "PASS", f"{ms:.0f}ms", ms)
elif r:
record(7, "7.10", "Invalid plugin returns 404", "WARN",
f"Expected 404, got {r.status_code}", ms)
else:
record(7, "7.10", "Invalid plugin returns 404", "FAIL", "No response", ms)
# ---------------------------------------------------------------------------
# Phase 8: BLE & UPS Hardware
# ---------------------------------------------------------------------------
def phase8_hardware(client: httpx.Client):
print("\n\033[1m═══ Phase 8: BLE & UPS Hardware ═══\033[0m")
# --- BLE ---
# 8.1 BLE status API
r, ms = timed_get(client, f"{API_BASE}/system/ble/status")
ble_data = {}
if r and r.status_code == 200:
ble_data = r.json()
record(8, "8.1", "BLE status API", "PASS",
f"enabled={ble_data.get('enabled')}, advertising={ble_data.get('advertising')}, name={ble_data.get('device_name')}", ms, ble_data)
else:
record(8, "8.1", "BLE status API", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 8.3 Start advertising (before scan so we have something to find)
r, ms = timed_post(client, f"{API_BASE}/system/ble/advertising/start")
if r and r.status_code == 200:
record(8, "8.3", "Start BLE advertising", "PASS", f"{ms:.0f}ms", ms)
else:
record(8, "8.3", "Start BLE advertising", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 8.2 BLE scan from laptop (after ensuring advertising is started)
import time as _time
_time.sleep(2) # Give BLE advertisement time to propagate
try:
scan_result = subprocess.run(
["bluetoothctl", "--timeout", "12", "scan", "le"],
capture_output=True, text=True, timeout=20
)
scan_output = scan_result.stdout + scan_result.stderr
if BLE_DEVICE_NAME.lower() in scan_output.lower() or "dangerous" in scan_output.lower():
record(8, "8.2", "BLE scan from laptop", "PASS",
f"Found {BLE_DEVICE_NAME} in BLE scan", 0)
else:
record(8, "8.2", "BLE scan from laptop", "WARN",
f"{BLE_DEVICE_NAME} not found in scan (may need longer scan)", 0)
except FileNotFoundError:
record(8, "8.2", "BLE scan from laptop", "SKIP", "bluetoothctl not found", 0)
except subprocess.TimeoutExpired:
record(8, "8.2", "BLE scan from laptop", "WARN", "Scan timed out", 0)
except Exception as e:
record(8, "8.2", "BLE scan from laptop", "WARN", f"Scan error: {e}", 0)
# 8.4 Stop advertising
r, ms = timed_post(client, f"{API_BASE}/system/ble/advertising/stop")
if r and r.status_code == 200:
record(8, "8.4", "Stop BLE advertising", "PASS", f"{ms:.0f}ms", ms)
else:
record(8, "8.4", "Stop BLE advertising", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 8.5 Send notification
r, ms = timed_post(client, f"{API_BASE}/system/ble/notify",
json={"type": "pm3_status", "message": "Hardware test notification"})
if r and r.status_code == 200:
record(8, "8.5", "Send BLE notification", "PASS", f"{ms:.0f}ms", ms)
else:
record(8, "8.5", "Send BLE notification", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# Restart advertising for normal operation
timed_post(client, f"{API_BASE}/system/ble/advertising/start")
# --- UPS ---
# 8.7 UPS status
r, ms = timed_get(client, f"{API_BASE}/system/ups/status")
ups_data = {}
if r and r.status_code == 200:
ups_data = r.json()
if ups_data.get("is_available"):
record(8, "8.7", "UPS status", "PASS",
f"battery={ups_data.get('battery_percentage')}%, voltage={ups_data.get('voltage')}V, source={ups_data.get('power_source')}", ms, ups_data)
else:
record(8, "8.7", "UPS status", "WARN",
f"UPS not available: {ups_data.get('error_message', 'no error info')}", ms, ups_data)
else:
record(8, "8.7", "UPS status", "FAIL",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 8.8 UPS thresholds
r, ms = timed_post(client, f"{API_BASE}/system/ups/thresholds",
json={"shutdown_threshold": 15, "warning_threshold": 25})
if r and r.status_code == 200:
record(8, "8.8", "UPS thresholds", "PASS", f"{ms:.0f}ms", ms)
elif r and r.status_code in (404, 503):
record(8, "8.8", "UPS thresholds", "WARN",
f"HTTP {r.status_code} (UPS may not be available)", ms)
else:
record(8, "8.8", "UPS thresholds", "WARN",
f"HTTP {r.status_code if r else 'timeout'}", ms)
# 8.9 Power source
source = ups_data.get("power_source", "unknown")
if source != "unknown":
record(8, "8.9", "Power source detection", "PASS", f"source={source}", 0)
else:
record(8, "8.9", "Power source detection", "WARN",
"Power source unknown (UPS may not be detected)", 0)
# 8.10 I2C via serial
if serial_console._ser and serial_console._ser.is_open:
out = serial_console.run_command(
"python3 -c \"import smbus2; bus=smbus2.SMBus(1); print('I2C OK:', hex(bus.read_byte(0x75))); bus.close()\" 2>&1"
)
if "i2c ok" in out.lower():
record(8, "8.10", "I2C device at 0x75", "PASS", "PiSugar responds", 0)
elif "error" in out.lower() or "errno" in out.lower():
record(8, "8.10", "I2C device at 0x75", "WARN", f"I2C error: {out.strip()[:100]}", 0)
else:
record(8, "8.10", "I2C device at 0x75", "WARN", f"Output: {out.strip()[:100]}", 0)
else:
record(8, "8.10", "I2C device at 0x75", "SKIP", "Serial not available", 0)
# ---------------------------------------------------------------------------
# Phase 9: Frontend & Performance
# ---------------------------------------------------------------------------
def phase9_frontend(client: httpx.Client):
print("\n\033[1m═══ Phase 9: Frontend & Performance ═══\033[0m")
pages = [
("9.1", "/", "Frontend loads", "root"),
("9.2", "/", "Dashboard page", "dashboard"),
("9.3", "/commands", "Commands page", "command"),
("9.4", "/settings", "Settings page", "settings"),
("9.5", "/logs", "Logs page", "log"),
]
for tid, path, name, keyword in pages:
try:
# Try HTTPS first (if enabled), then fall back to HTTP
try:
r = httpx.get(f"{HTTPS_BASE}{path}", timeout=10.0, verify=False)
except Exception:
r = httpx.get(f"{HTTP_UI}{path}", timeout=10.0, follow_redirects=True)
t_ms = 0
if r.status_code == 200:
body = r.text.lower()
# Remix apps should have root element and scripts
is_app = "root" in body or "<script" in body or "remix" in body
if is_app:
record(9, tid, name, "PASS",
f"{len(r.text)} bytes, has app markup", t_ms)
else:
record(9, tid, name, "WARN",
f"Got HTML but no app markers ({len(r.text)} bytes)", t_ms)
else:
record(9, tid, name, "FAIL", f"HTTP {r.status_code}", t_ms)
except Exception as e:
record(9, tid, name, "FAIL", f"Error: {e}", 0)
# 9.6 Response time benchmark
times = []
for _ in range(10):
_, ms = timed_get(client, f"{API_BASE}/health")
times.append(ms)
avg = sum(times) / len(times)
p95 = sorted(times)[int(len(times) * 0.95)]
record(9, "9.6", "Response time (10 requests)", "PASS" if avg < 100 else "WARN",
f"avg={avg:.0f}ms, p95={p95:.0f}ms, min={min(times):.0f}ms, max={max(times):.0f}ms", avg)
# 9.7 Memory usage
r, _ = timed_get(client, f"{API_BASE}/system/info")
if r and r.status_code == 200:
data = r.json()
mem_mb = data.get("memory_used", 0) / 1024 / 1024
record(9, "9.7", "Memory usage", "PASS" if mem_mb < 200 else "WARN",
f"system total used: {mem_mb:.0f}MB", 0, data)
else:
record(9, "9.7", "Memory usage", "SKIP", "Could not get system info", 0)
# 9.8 CPU temperature
if r and r.status_code == 200:
data = r.json()
temp = data.get("cpu_temp", data.get("cpu", {}).get("temperature", 0))
record(9, "9.8", "CPU temperature", "PASS" if temp < 70 else "WARN",
f"{temp}°C (target < 70°C idle)", 0)
else:
record(9, "9.8", "CPU temperature", "SKIP", "Could not get system info", 0)
# ---------------------------------------------------------------------------
# Report generator
# ---------------------------------------------------------------------------
def generate_report() -> str:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
total = len(results)
passed = sum(1 for r in results if r.status == "PASS")
failed = sum(1 for r in results if r.status == "FAIL")
warned = sum(1 for r in results if r.status == "WARN")
skipped = sum(1 for r in results if r.status == "SKIP")
lines = [
f"# Dangerous Pi - Hardware Test Results",
f"",
f"**Test Date**: {now}",
f"**Target**: {PI_HOST} ({API_BASE})",
f"**Serial**: {SERIAL_PORT} @ {SERIAL_BAUD}",
f"**Tester**: Automated (test_hardware_2026-03-03.py)",
f"",
f"---",
f"",
f"## Summary",
f"",
f"| Metric | Count |",
f"|--------|-------|",
f"| Total Tests | {total} |",
f"| **PASS** | {passed} |",
f"| **FAIL** | {failed} |",
f"| **WARN** | {warned} |",
f"| **SKIP** | {skipped} |",
f"| **Pass Rate** | {passed/total*100:.1f}% (excl. skips: {passed/max(total-skipped,1)*100:.1f}%) |",
f"",
f"---",
f"",
]
# Group by phase
phases = {
1: "Phase 1: Regression - Jan 23 Issues",
2: "Phase 2: Core Health & System Endpoints",
3: "Phase 3: PM3 Hardware Integration",
4: "Phase 4: WiFi Manager",
5: "Phase 5: HTTPS & SSL",
6: "Phase 6: Authentication & WebSocket",
7: "Phase 7: Plugin System & Hooks",
8: "Phase 8: BLE & UPS Hardware",
9: "Phase 9: Frontend & Performance",
}
for phase_num, phase_name in phases.items():
phase_results = [r for r in results if r.phase == phase_num]
if not phase_results:
continue
p_pass = sum(1 for r in phase_results if r.status == "PASS")
p_fail = sum(1 for r in phase_results if r.status == "FAIL")
p_warn = sum(1 for r in phase_results if r.status == "WARN")
p_skip = sum(1 for r in phase_results if r.status == "SKIP")
icon = "" if p_fail == 0 and p_warn == 0 else ("" if p_fail > 0 else "⚠️")
lines.append(f"## {icon} {phase_name}")
lines.append(f"")
lines.append(f"| # | Test | Status | Detail |")
lines.append(f"|---|------|--------|--------|")
for r in phase_results:
status_icon = {"PASS": "", "FAIL": "", "WARN": "⚠️", "SKIP": "⏭️"}[r.status]
detail = r.detail.replace("|", "\\|")[:120]
lines.append(f"| {r.test_id} | {r.name} | {status_icon} {r.status} | {detail} |")
lines.append(f"")
lines.append(f"**Phase Summary**: {p_pass} pass, {p_fail} fail, {p_warn} warn, {p_skip} skip")
lines.append(f"")
lines.append(f"---")
lines.append(f"")
# Issues section
failures = [r for r in results if r.status == "FAIL"]
warnings = [r for r in results if r.status == "WARN"]
if failures:
lines.append(f"## Issues Found (FAIL)")
lines.append(f"")
for r in failures:
lines.append(f"### {r.test_id}: {r.name}")
lines.append(f"- **Detail**: {r.detail}")
if r.data:
lines.append(f"- **Data**: `{json.dumps(r.data)[:300]}`")
lines.append(f"")
if warnings:
lines.append(f"## Warnings")
lines.append(f"")
for r in warnings:
lines.append(f"- **{r.test_id} {r.name}**: {r.detail}")
lines.append(f"")
lines.append(f"---")
lines.append(f"")
lines.append(f"*Generated by test_hardware_2026-03-03.py*")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Dangerous Pi Hardware Test Suite")
parser.add_argument("--phase", type=int, help="Run specific phase (1-9)")
parser.add_argument("--safe-only", action="store_true",
help="Skip destructive tests")
parser.add_argument("--no-serial", action="store_true",
help="Skip serial console tests")
parser.add_argument("--report", default="TEST_RESULTS_2026-03-03.md",
help="Output report file")
args = parser.parse_args()
print(f"\033[1m{'='*60}\033[0m")
print(f"\033[1m Dangerous Pi - Hardware Integration Test Suite\033[0m")
print(f"\033[1m Target: {PI_HOST} | Serial: {SERIAL_PORT}\033[0m")
print(f"\033[1m Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\033[0m")
print(f"\033[1m{'='*60}\033[0m")
# Open serial console
if not args.no_serial:
print(f"\n[*] Opening serial console {SERIAL_PORT}...")
if serial_console.open():
print(f" Serial console connected")
# Send a newline to ensure prompt
serial_console.run_command("", timeout=1)
else:
print(f" Serial console not available (tests will skip serial checks)")
# Create HTTP client
client = httpx.Client(timeout=REQUEST_TIMEOUT)
# Quick connectivity check
print(f"\n[*] Checking connectivity to {PI_HOST}...")
r, ms = timed_get(client, f"{API_BASE}/health")
if not r or r.status_code != 200:
print(f"\033[31m FATAL: Cannot reach {API_BASE}/health\033[0m")
print(f" Ensure the Pi is running and accessible")
sys.exit(1)
print(f" Backend healthy ({ms:.0f}ms)")
# Run phases
phase_map = {
1: lambda: phase1_regression(client),
2: lambda: phase2_system(client),
3: lambda: phase3_pm3(client),
4: lambda: phase4_wifi(client),
5: lambda: phase5_https(client),
6: lambda: phase6_auth(client, args.safe_only),
7: lambda: phase7_plugins(client),
8: lambda: phase8_hardware(client),
9: lambda: phase9_frontend(client),
}
if args.phase:
if args.phase in phase_map:
phase_map[args.phase]()
else:
print(f"Unknown phase {args.phase}. Valid: 1-9")
sys.exit(1)
else:
for phase_func in phase_map.values():
phase_func()
# Cleanup
serial_console.close()
client.close()
# Print summary
total = len(results)
passed = sum(1 for r in results if r.status == "PASS")
failed = sum(1 for r in results if r.status == "FAIL")
warned = sum(1 for r in results if r.status == "WARN")
skipped = sum(1 for r in results if r.status == "SKIP")
print(f"\n\033[1m{'='*60}\033[0m")
print(f"\033[1m RESULTS: {passed} pass, {failed} fail, {warned} warn, {skipped} skip / {total} total\033[0m")
if failed == 0:
print(f"\033[32m All non-skipped tests passed!\033[0m")
else:
print(f"\033[31m {failed} test(s) FAILED\033[0m")
print(f"\033[1m{'='*60}\033[0m")
# Generate report
report = generate_report()
report_path = Path(args.report)
report_path.write_text(report)
print(f"\n Report written to: {report_path}")
# Exit with failure code if any tests failed
sys.exit(1 if failed > 0 else 0)
if __name__ == "__main__":
main()