diff options
| author | Your Name <you@example.com> | 2026-05-23 02:06:10 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-23 02:06:10 +0530 |
| commit | 969dfe9ea52ed304ae44d7f7adbc5219f90497dd (patch) | |
| tree | 127481ab6a6e4a963e4ac330051cecf57a40f511 /PLAN_pytest_migration.md | |
| parent | 0c95564af9ba2ab772b5269970d0153c9a59c36f (diff) | |
pytests pass, now proceeding to spearate out tollgate core
Diffstat (limited to 'PLAN_pytest_migration.md')
| -rw-r--r-- | PLAN_pytest_migration.md | 284 |
1 files changed, 284 insertions, 0 deletions
diff --git a/PLAN_pytest_migration.md b/PLAN_pytest_migration.md new file mode 100644 index 0000000..dfea3d9 --- /dev/null +++ b/PLAN_pytest_migration.md | |||
| @@ -0,0 +1,284 @@ | |||
| 1 | # ESP32 pytest Migration Plan | ||
| 2 | |||
| 3 | Migrate ESP32 board integration tests from Node.js `.mjs` + Makefile to pytest with proper fixtures for board provisioning (mutex locks, flashing, SPIFFS config, teardown). | ||
| 4 | |||
| 5 | ## Architecture | ||
| 6 | |||
| 7 | ### New Files | ||
| 8 | |||
| 9 | | File | Location | Purpose | | ||
| 10 | |------|----------|---------| | ||
| 11 | | `esp32_board.py` | `lib/` | `ESP32Board` class: lock, flash, config, reset, HTTP API, wait_for_boot | | ||
| 12 | | `conftest.py` | `tests/esp32/` | Board fixtures (`board_a`, `board_b`, `board_c`), marker registration, `.env` loading | | ||
| 13 | | `pytest-esp32.ini` | project root | Separate pytest config for ESP32 tests | | ||
| 14 | |||
| 15 | ### Migrated Tests | ||
| 16 | |||
| 17 | | Priority | Source (.mjs) | Target (pytest) | Payment? | Boards | | ||
| 18 | |----------|--------------|-----------------|----------|--------| | ||
| 19 | | 1 | `tests/integration/api.mjs` | `tests/esp32/test_api.py` | No | a | | ||
| 20 | | 2 | `tests/integration/smoke.mjs` | `tests/esp32/test_smoke.py` | No | a | | ||
| 21 | | 3 | `tests/integration/test-cvm-roundtrip.mjs` | `tests/esp32/test_cvm.py` | No | a | | ||
| 22 | | 4 | `tests/integration/test-local-relay.mjs` | `tests/esp32/test_local_relay.py` | No | a | | ||
| 23 | | 5 | `tests/integration/test-relay-nip11.mjs` | `tests/esp32/test_relay_nip11.py` | No | a | | ||
| 24 | | 6 | `tests/integration/test-market.mjs` | `tests/esp32/test_market.py` | No | a | | ||
| 25 | | 7 | `tests/integration/test-cross-board.mjs` | `tests/esp32/test_cross_board.py` | No | a, c | | ||
| 26 | | 8 | `tests/integration/test-reset-auth.mjs` | `tests/esp32/test_reset_auth.py` | Yes | a | | ||
| 27 | | 9 | `tests/integration/test-session-expiry.mjs` | `tests/esp32/test_session_expiry.py` | Yes | a | | ||
| 28 | | 10 | `tests/integration/test-dns-firewall.mjs` | `tests/esp32/test_dns_firewall.py` | Yes | a | | ||
| 29 | |||
| 30 | All paths are relative to `/home/c03rad0r/physical-router-test-automation/`. | ||
| 31 | |||
| 32 | --- | ||
| 33 | |||
| 34 | ## `lib/esp32_board.py` — ESP32Board Helper | ||
| 35 | |||
| 36 | ```python | ||
| 37 | class ESP32Board: | ||
| 38 | """ESP32 board: lock, flash, SPIFFS config, reset, HTTP API access.""" | ||
| 39 | |||
| 40 | LOCK_DIR = "/home/c03rad0r/physical-router-test-automation/locks" | ||
| 41 | SPIFFS_OFFSET = 0x410000 | ||
| 42 | SPIFFS_SIZE = 0xF0000 | ||
| 43 | IDF_PATH = os.environ.get("IDF_PATH", os.path.expanduser("~/esp/esp-idf")) | ||
| 44 | SPIFFSGEN = f"{IDF_PATH}/components/spiffs/spiffsgen.py" | ||
| 45 | |||
| 46 | def __init__(self, board_id, port, nsec, ssid, ip, worktree): | ||
| 47 | self.board_id = board_id # "a", "b", "c" | ||
| 48 | self.port = port # "/dev/ttyACM0" | ||
| 49 | self.nsec = nsec | ||
| 50 | self.ssid = ssid # derived from nsec via HMAC-SHA512 | ||
| 51 | self.ip = ip # derived AP IP | ||
| 52 | self.worktree = worktree # /home/c03rad0r/esp32-tollgate | ||
| 53 | self._lock_held = False | ||
| 54 | |||
| 55 | # --- Lock (board-{id}.lock, same as Makefile) --- | ||
| 56 | def acquire_lock(self, phase="pytest"): ... | ||
| 57 | def release_lock(self): ... | ||
| 58 | |||
| 59 | # --- Firmware --- | ||
| 60 | def build(self): ... # idf.py build (in worktree) | ||
| 61 | def flash(self, baud=460800): ... # build + idf.py -p {port} flash | ||
| 62 | |||
| 63 | # --- SPIFFS Config --- | ||
| 64 | def write_config(self, wifi_ssid, wifi_password, mint_url, **extra): ... | ||
| 65 | def write_config_ap_only(self, **extra): ... | ||
| 66 | # 1. mktemp -d | ||
| 67 | # 2. Write config.json with nsec, wifi_networks, mint_url, price, relays | ||
| 68 | # 3. python3 spiffsgen.py --page-size 256 --obj-name-len 32 \ | ||
| 69 | # --use-magic --use-magic-len 0xF0000 tmpdir/ tmpdir/spiffs.bin | ||
| 70 | # 4. esptool --port {port} --baud {baud} write_flash 0x410000 spiffs.bin | ||
| 71 | # 5. esptool --port {port} run (reset) | ||
| 72 | |||
| 73 | # --- Reset --- | ||
| 74 | def reset(self): ... # esptool --port {port} run | ||
| 75 | |||
| 76 | # --- Wait --- | ||
| 77 | def wait_for_boot(self, timeout=30): ... # poll http://{ip}:2121/ | ||
| 78 | def wait_for_ap(self, timeout=30): ... # poll ping {ip} | ||
| 79 | |||
| 80 | # --- HTTP API (port 2121) --- | ||
| 81 | def api_get(self, path="/") -> (int, str): ... | ||
| 82 | def api_post(self, path, data="") -> (int, str): ... | ||
| 83 | |||
| 84 | # --- Captive Portal (port 80) --- | ||
| 85 | def http_get(self, path="/") -> (int, str): ... | ||
| 86 | |||
| 87 | # --- WiFi client (Linux nmcli) --- | ||
| 88 | def connect_wifi(self, interface="wlp59s0"): ... | ||
| 89 | def disconnect_wifi(self, interface="wlp59s0"): ... | ||
| 90 | |||
| 91 | # --- Context manager --- | ||
| 92 | def __enter__(self): return self | ||
| 93 | def __exit__(self, *exc): self.release_lock() | ||
| 94 | ``` | ||
| 95 | |||
| 96 | ## `pytest-esp32.ini` | ||
| 97 | |||
| 98 | ```ini | ||
| 99 | [pytest] | ||
| 100 | pythonpath = . | ||
| 101 | testpaths = tests/esp32 | ||
| 102 | python_files = test_*.py | ||
| 103 | python_functions = test_* | ||
| 104 | markers = | ||
| 105 | boards(*ids): specify which ESP32 boards to provision | ||
| 106 | api: API-level test (HTTP only, no browser) | ||
| 107 | smoke: quick connectivity check | ||
| 108 | payment: requires mint/Cashu token | ||
| 109 | cvm: ContextVM MCP test | ||
| 110 | relay: Nostr relay test | ||
| 111 | cross_board: requires multiple boards | ||
| 112 | addopts = -v --tb=short --timeout=120 --timeout-method=thread | ||
| 113 | ``` | ||
| 114 | |||
| 115 | ## `tests/esp32/conftest.py` — Fixtures | ||
| 116 | |||
| 117 | ```python | ||
| 118 | # Loads .env from /home/c03rad0r/esp32-tollgate/.env | ||
| 119 | # Board registry maps board_id -> env var names | ||
| 120 | |||
| 121 | BOARD_REGISTRY = { | ||
| 122 | "a": dict(port_env="PORT_A", nsec_env="BOARD_A_NSEC", | ||
| 123 | ssid_env="BOARD_A_SSID", ip_env="BOARD_A_IP"), | ||
| 124 | "b": dict(port_env="PORT_B", nsec_env="BOARD_B_NSEC", | ||
| 125 | ssid_env="BOARD_B_SSID", ip_env="BOARD_B_IP"), | ||
| 126 | "c": dict(port_env="PORT_C", nsec_env="BOARD_C_NSEC", | ||
| 127 | ssid_env="BOARD_C_SSID", ip_env="BOARD_C_IP"), | ||
| 128 | } | ||
| 129 | |||
| 130 | # CLI options | ||
| 131 | --esp32-no-flash Skip firmware flash (assume already flashed) | ||
| 132 | --esp32-no-config Skip SPIFFS config write | ||
| 133 | --esp32-worktree Path to esp32-tollgate (default: /home/c03rad0r/esp32-tollgate) | ||
| 134 | |||
| 135 | # Fixtures (session-scoped, lazy provisioning) | ||
| 136 | @pytest.fixture(scope="session") | ||
| 137 | def board_a(request): ... | ||
| 138 | # 1. acquire_lock | ||
| 139 | # 2. flash (unless --esp32-no-flash) | ||
| 140 | # 3. write_config (unless --esp32-no-config) | ||
| 141 | # 4. wait_for_boot | ||
| 142 | # 5. yield board | ||
| 143 | # 6. release_lock | ||
| 144 | |||
| 145 | @pytest.fixture(scope="session") | ||
| 146 | def board_b(request): ... # same pattern | ||
| 147 | |||
| 148 | @pytest.fixture(scope="session") | ||
| 149 | def board_c(request): ... # same pattern | ||
| 150 | |||
| 151 | @pytest.fixture(scope="session") | ||
| 152 | def cashu_esp32(): ... # CashuMint for test mint | ||
| 153 | ``` | ||
| 154 | |||
| 155 | ## Marker-based Board Selection | ||
| 156 | |||
| 157 | Tests declare which boards they need via `@pytest.mark.boards("a", "c")`. | ||
| 158 | Only those board fixtures are provisioned. Tests requesting boards not | ||
| 159 | available in the session are skipped. | ||
| 160 | |||
| 161 | ```python | ||
| 162 | # Single board | ||
| 163 | pytestmark = [pytest.mark.api, pytest.mark.boards("a")] | ||
| 164 | |||
| 165 | # Multiple boards | ||
| 166 | pytestmark = [pytest.mark.cross_board, pytest.mark.boards("a", "c")] | ||
| 167 | ``` | ||
| 168 | |||
| 169 | ## Setup/Teardown Lifecycle | ||
| 170 | |||
| 171 | ``` | ||
| 172 | pytest -c pytest-esp32.ini | ||
| 173 | | | ||
| 174 | +-- pytest_sessionstart: (board fixtures are lazy) | ||
| 175 | | | ||
| 176 | +-- First test requests board_a: | ||
| 177 | | +-- acquire_lock("board-a") -> board-a.lock | ||
| 178 | | +-- flash() -> idf.py build + flash (~60s) | ||
| 179 | | +-- write_config() -> spiffsgen + esptool write_flash + reset (~5s) | ||
| 180 | | +-- wait_for_boot() -> poll :2121 until 200 (~10s) | ||
| 181 | | +-- yield board_a | ||
| 182 | | | ||
| 183 | +-- Tests run using board_a.api_get(), board_a.http_get(), etc. | ||
| 184 | | | ||
| 185 | +-- pytest_sessionfinish: | ||
| 186 | | +-- board_a.release_lock() -> remove board-a.lock | ||
| 187 | | | ||
| 188 | +-- Done | ||
| 189 | ``` | ||
| 190 | |||
| 191 | ## CLI Usage | ||
| 192 | |||
| 193 | ```bash | ||
| 194 | # Run all ESP32 tests (flash + configure by default) | ||
| 195 | pytest -c pytest-esp32.ini | ||
| 196 | |||
| 197 | # Skip flash (faster, assumes firmware already on board) | ||
| 198 | pytest -c pytest-esp32.ini --esp32-no-flash | ||
| 199 | |||
| 200 | # Skip config too (board already configured) | ||
| 201 | pytest -c pytest-esp32.ini --esp32-no-flash --esp32-no-config | ||
| 202 | |||
| 203 | # Run only smoke tests | ||
| 204 | pytest -c pytest-esp32.ini -m smoke | ||
| 205 | |||
| 206 | # Run only cross-board tests (needs boards a + c) | ||
| 207 | pytest -c pytest-esp32.ini -m cross_board | ||
| 208 | |||
| 209 | # Single test file | ||
| 210 | pytest -c pytest-esp32.ini tests/esp32/test_api.py | ||
| 211 | |||
| 212 | # Verbose with no timeout (debugging) | ||
| 213 | pytest -c pytest-esp32.ini tests/esp32/test_api.py -v --timeout=0 | ||
| 214 | ``` | ||
| 215 | |||
| 216 | ## Key Design Decisions | ||
| 217 | |||
| 218 | 1. **Flash by default** — guarantees known-good firmware state per session | ||
| 219 | 2. **Merged into existing `esp32/` test suite** — removed duplicate `tests/esp32/`, added new tests to `esp32/tests/` | ||
| 220 | 3. **Same lock files as Makefile** — `board-{a,b,c}.lock` in `physical-router-test-automation/locks/`, mutually exclusive with `make flash-a` etc. | ||
| 221 | 4. **Board C support** — `esp32/boards.env` and `esp32/boards.py` support all three boards | ||
| 222 | 5. **Session-scoped `board_connected`** — connects WiFi once per session | ||
| 223 | 6. **`--board` flag** — `pytest --board=a` selects board via marker filtering | ||
| 224 | 7. **Test ordering matters** — smoke tests first (they're self-contained), then API (some reset auth), then relay | ||
| 225 | 8. **Existing fixtures** — `board_connected`, `http`, `wifi` from `esp32/conftest.py`; added `relay`, `dns` helpers | ||
| 226 | |||
| 227 | ## Board Identity Reference | ||
| 228 | |||
| 229 | | Board | Port | SSID | AP IP | nsec (first 8) | | ||
| 230 | |-------|------|------|-------|-----------------| | ||
| 231 | | A | `/dev/ttyACM2` | `TollGate-B96D80` | `10.185.47.1` | `9af47906...` | | ||
| 232 | | B | `/dev/ttyACM1` | `TollGate-C0E9CA` | `10.192.45.1` | `a1b2c3d4...` | | ||
| 233 | | C | `/dev/ttyACM0` | `TollGate-4A2510` | `10.74.63.1` | `71bf3f4d...` | | ||
| 234 | |||
| 235 | Ports change on every USB replug. Verify with `esptool.py --port <port> chip_id`. | ||
| 236 | |||
| 237 | --- | ||
| 238 | |||
| 239 | ## Checklist | ||
| 240 | |||
| 241 | ### Infrastructure | ||
| 242 | |||
| 243 | - [x] Create `lib/esp32_board.py` — ESP32Board class (standalone, for programmatic use) | ||
| 244 | - [x] Merge into existing `esp32/` test suite (conftest.py, pytest.ini, boards.py) | ||
| 245 | - [x] Update `esp32/boards.env` with correct port assignments | ||
| 246 | - [x] Add `relay` and `dns` helpers to `esp32/conftest.py` | ||
| 247 | - [x] Add nsec env vars to `esp32/boards.env` (for CVM tests) | ||
| 248 | - [x] Add Makefile targets for new test files | ||
| 249 | |||
| 250 | ### Test Files in `esp32/tests/` | ||
| 251 | |||
| 252 | **Existing (pre-migration):** | ||
| 253 | - [x] `test_smoke.py` — 6 tests, SSID visible, portal, grant, internet, reset | ||
| 254 | - [x] `test_api.py` — 9 tests, debug, discovery, mints, wallet, whoami, usage, captcha URIs | ||
| 255 | - [x] `test_captive_portal.py` — 7 tests, branding, price, input, button, placeholders, mint URL | ||
| 256 | - [x] `test_multi_mint.py` — 4 tests, pricing, mint list, health, wallet | ||
| 257 | - [x] `test_wallet_funding.py` — 2 tests, fund from upstream, spend (spend skipped) | ||
| 258 | |||
| 259 | **New (migrated from .mjs):** | ||
| 260 | - [x] `test_local_relay.py` — 5 tests, WS connect, REQ/EOSE, publish, close, multi-conn | ||
| 261 | - [x] `test_relay_nip11.py` — 4 tests, JSON, fields, NIP support, name | ||
| 262 | - [x] `test_market.py` — 3 tests, JSON, entry structure, empty state | ||
| 263 | - [x] `test_cvm.py` — 4 tests, API reachable, 11316/11317 announcements, MCP roundtrip | ||
| 264 | - [x] `test_reset_auth.py` — 6 tests, reset, payment, session lifecycle | ||
| 265 | - [x] `test_session_expiry.py` — 1 test, 65s wait for expiry + renewal | ||
| 266 | - [x] `test_dns_firewall.py` — 10 tests, DNS hijack, NAT filter, before/after auth/revoke | ||
| 267 | |||
| 268 | ### Verification | ||
| 269 | |||
| 270 | - [x] `pytest --collect-only` — 60 tests collected from 12 files | ||
| 271 | - [x] **37 passed, 1 skipped** — `pytest test_smoke.py test_api.py test_captive_portal.py test_multi_mint.py test_market.py test_relay_nip11.py test_local_relay.py --board=a` | ||
| 272 | - [x] Lock files created/cleaned: `board-a.lock` lifecycle verified | ||
| 273 | - [x] Relay tests pass: WS connect, EOSE, publish, close, NIP-11 | ||
| 274 | - [x] Market tests pass: JSON, entry structure | ||
| 275 | - [ ] Payment-dependent tests (reset_auth, session_expiry, dns_firewall AfterAuth/AfterRevoke) — need mint available | ||
| 276 | - [ ] Cross-board tests — need Board C provisioned and reachable | ||
| 277 | - [ ] CVM roundtrip — needs nsec env var set and board connected to internet | ||
| 278 | |||
| 279 | ### Known Issues | ||
| 280 | |||
| 281 | 1. **Test ordering required** — `test_smoke.py` must run first (it's self-contained). `test_api.py` calls `reset_authentication` in `test_usage_endpoint_no_session` which breaks portal access for subsequent tests. Solution: run smoke first. | ||
| 282 | 2. **Relay max connections** — ESP32 relay supports ~2 simultaneous WebSocket connections. `test_multiple_connections` tests with 2 connections. | ||
| 283 | 3. **Board crashes after destructive tests** — `reset_authentication` in DNS/firewall tests can crash the HTTP server after multiple rapid resets. Board needs physical reset between test groups. | ||
| 284 | 4. **Payment-dependent tests blocked** — Mint intermittently returns `sqlite3.OperationalError: unable to open database file`. Tests work when mint is healthy. | ||