diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a94b0615..4b6acb6f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,9 +1,14 @@ # Copyright 2024-2026 Lager Data # SPDX-License-Identifier: Apache-2.0 # -# CLI unit tests. These need no hardware and no box, so they run on a -# GitHub-hosted runner and are safe to gate pull requests (no self-hosted -# runner is exposed to fork PRs here). +# Non-hardware unit tests. They need no bench and no self-hosted runner, so +# they run on GitHub-hosted ubuntu-latest and gate pull requests. +# +# Each suite runs as its OWN matrix job because the suites cannot share a +# pytest process: test/unit/measurement/conftest.py stubs the `lager` package +# in sys.modules (to avoid heavy box deps), which breaks box-suite collection. +# `--import-mode=importlib` avoids clashes between same-named test modules +# across suites, and `-c /dev/null` ignores any repo-level pytest config. name: Unit Tests @@ -15,9 +20,31 @@ on: jobs: unit-tests: - name: CLI unit tests + name: unit (${{ matrix.name }}) runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 + env: + # Box tests import the `lager` package (box/lager), and some spawn + # subprocesses that import `test.*`. Put repo root and box/ on PYTHONPATH + # so `lager` and `test.*` resolve consistently — in-process AND in + # subprocesses — regardless of OS or how pytest is invoked. + PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/box + strategy: + fail-fast: false + matrix: + include: + - name: cli + path: test/unit/cli/ + - name: box + path: test/unit/box/ + - name: measurement + path: test/unit/measurement/ + - name: blufi + path: test/unit/blufi/ + - name: mcp + path: test/mcp/unit/ + - name: root + path: test/unit/test_*.py test/test_*.py steps: - name: Checkout repository @@ -28,12 +55,13 @@ jobs: with: python-version: '3.11' - - name: Install lager CLI (editable) + pytest + - name: Install CLI + box-side test deps run: | pip install -e cli/ - pip install pytest + pip install pytest pytest-timeout + pip install -r test/requirements-unit.txt shell: bash - - name: Run CLI unit tests - run: pytest test/unit/cli/ -v + - name: Run ${{ matrix.name }} unit tests + run: pytest ${{ matrix.path }} -v --import-mode=importlib -c /dev/null --timeout=60 shell: bash diff --git a/test/requirements-unit.txt b/test/requirements-unit.txt new file mode 100644 index 00000000..7979f630 --- /dev/null +++ b/test/requirements-unit.txt @@ -0,0 +1,26 @@ +# Copyright 2024-2026 Lager Data +# SPDX-License-Identifier: Apache-2.0 +# +# Runtime dependencies required to IMPORT the box-side package tree so its +# non-hardware unit tests can run headless on a GitHub-hosted runner: +# test/unit/box/ test/unit/measurement/ test/unit/blufi/ test/mcp/unit/ +# +# Hardware SDKs (joulescope, ppk2_api, yoctopuce, labjack-ljm, brainstem, +# uldaq, phidget22, pyftdi, ...) are intentionally NOT listed: the test +# conftests mock them, and the box unit tests load individual modules by path +# to avoid importing them. Everything here is a pure-Python wheel. +numpy +simplejson +pyvisa +pyvisa-py +flask +pyserial +pyusb +cryptography +fastmcp +pexpect +pygdbmi # box/lager/debug/gdb.py — GDB/MI controller (needed to import lager.debug) +flask-socketio # box/lager/http_handlers — websocket monitors (needed to import lager.http_handlers) +# NB: pymupdf (PyMuPDF) is intentionally NOT listed — it is AGPL-3.0, which +# conflicts with this project's Apache-2.0 license. Without it, +# test/unit/test_pdf_pages.py skips (it importorskips fitz); that's acceptable. diff --git a/test/unit/box/test_box_authorize.py b/test/unit/box/test_box_authorize.py index 41580966..b2dbd68f 100644 --- a/test/unit/box/test_box_authorize.py +++ b/test/unit/box/test_box_authorize.py @@ -55,6 +55,7 @@ def test_missing_key_runs_ssh_keygen(self): run = RecordingRun([_proc(0)]) with patch.object(_ssh, "subprocess") as sub, \ patch.object(_ssh.os.path, "exists", lambda p: False), \ + patch.object(_ssh.shutil, "which", lambda name: "ssh-keygen"), \ patch.object(_ssh.os, "makedirs", lambda *a, **k: None): sub.run = run self.assertTrue(_ssh.ensure_lager_box_keypair("/tmp/nope/lager_box")) @@ -67,6 +68,7 @@ def test_keygen_failure_raises_lager_error(self): run = RecordingRun([_proc(1, stderr="disk full")]) with patch.object(_ssh, "subprocess") as sub, \ patch.object(_ssh.os.path, "exists", lambda p: False), \ + patch.object(_ssh.shutil, "which", lambda name: "ssh-keygen"), \ patch.object(_ssh.os, "makedirs", lambda *a, **k: None): sub.run = run with self.assertRaises(LagerError): diff --git a/test/unit/box/test_breakpoint_pause.py b/test/unit/box/test_breakpoint_pause.py index cc01317f..38a1f9cd 100644 --- a/test/unit/box/test_breakpoint_pause.py +++ b/test/unit/box/test_breakpoint_pause.py @@ -184,7 +184,28 @@ def test_stale_resume_marker_is_cleared(tmp_path, monkeypatch): assert time.monotonic() - start >= 0.2 -def test_interactive_console_evaluates_script_state(monkeypatch): +@pytest.fixture +def _ignore_sigpipe(): + """Replicate the box's SIGPIPE handling (box_http_server.py sets SIG_IGN). + + The socket console already absorbs BrokenPipeError (see _serve/_interact), + but if SIGPIPE is at its default disposition a client that disconnects + mid-write delivers the signal and kills the interpreter (exit 141) on Linux + before the handler runs. Ignoring it here — as the box does in production — + turns that into the BrokenPipeError the console already handles. + """ + import signal + if not hasattr(signal, "SIGPIPE"): + yield + return + old = signal.signal(signal.SIGPIPE, signal.SIG_IGN) + try: + yield + finally: + signal.signal(signal.SIGPIPE, old) + + +def test_interactive_console_evaluates_script_state(monkeypatch, _ignore_sigpipe): """End-to-end proof the interactive console can read the paused script's namespace, run statements, and survive errors — all over the socket.""" monkeypatch.setattr(bp, "CONSOLE_PORT_RANGE", range(8401, 8411)) diff --git a/test/unit/box/test_monitor_state.py b/test/unit/box/test_monitor_state.py index a9d48ecc..147a3017 100644 --- a/test/unit/box/test_monitor_state.py +++ b/test/unit/box/test_monitor_state.py @@ -130,6 +130,8 @@ def boom(self, channel=None): supply_net, get_overcurrent_protection_value=boom, get_overvoltage_protection_value=boom, + overcurrent_protection_is_tripped=boom, + overvoltage_protection_is_tripped=boom, ).get_monitor_state(1) assert state["ocp_limit"] is None assert state["ocp_tripped"] is None