Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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
26 changes: 26 additions & 0 deletions test/requirements-unit.txt
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +12 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure reproducible test environments and prevent unexpected failures in CI due to breaking changes in upstream packages, it is highly recommended to pin these dependencies to specific versions or version ranges (e.g., numpy>=1.20,<2.0).

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.
2 changes: 2 additions & 0 deletions test/unit/box/test_box_authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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):
Expand Down
23 changes: 22 additions & 1 deletion test/unit/box/test_breakpoint_pause.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions test/unit/box/test_monitor_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading