Repository navigation
fix(cli): detect Windows USB serial ports in ebuild monitor
#185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
60403ab
2aee92e
afc927c
6661605
3d6c368
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| # SPDX-License-Identifier: MIT | ||
| # Copyright (c) 2026 EoS Project | ||
|
|
||
| import sys | ||
| import types | ||
|
|
||
| from click.testing import CliRunner | ||
| import pytest | ||
|
|
||
| import ebuild.cli.commands as commands | ||
|
|
||
| from ebuild.cli.commands import _serial_ports, monitor | ||
|
|
||
|
|
||
| class RecordingLogger: | ||
| def __init__(self): | ||
| self.errors = [] | ||
| self.infos = [] | ||
|
|
||
| def header(self, msg): | ||
| pass | ||
|
|
||
| def step(self, msg): | ||
| pass | ||
|
|
||
| def info(self, msg): | ||
| self.infos.append(msg) | ||
|
|
||
| def success(self, msg): | ||
| pass | ||
|
|
||
| def error(self, msg): | ||
| self.errors.append(msg) | ||
|
|
||
| def warning(self, msg): | ||
| pass | ||
|
|
||
| def debug(self, msg): | ||
|
Comment on lines
+15
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ Low · New public functions lack type hints When the new test module is added, the added public test functions, fixture, and class methods omit parameter and return annotations, so the new Python code does not meet the repository’s type-hint requirement. Add parameter and return type annotations to the new public functions and methods. Also at Prompt for AI agentsIn `tests/unit/cli/test_serial_ports.py` at lines 15-37:
**New public functions lack type hints**
When the new test module is added, the added public test functions, fixture, and class methods omit parameter and return annotations, so the new Python code does not meet the repository’s type-hint requirement.
Add parameter and return type annotations to the new public functions and methods.
Also at `tests/unit/cli/test_serial_ports.py:67`, `tests/unit/cli/test_serial_ports.py:71`, `tests/unit/cli/test_serial_ports.py:80`, `tests/unit/cli/test_serial_ports.py:89`, `tests/unit/cli/test_serial_ports.py:99`, `tests/unit/cli/test_serial_ports.py:108`, `tests/unit/cli/test_serial_ports.py:118`, `tests/unit/cli/test_serial_ports.py:122-132`, `tests/unit/cli/test_serial_ports.py:147`.From ### Python (PEP 8) |
||
| pass | ||
|
|
||
|
|
||
| def _port(device, vid): | ||
| return types.SimpleNamespace(device=device, vid=vid) | ||
|
|
||
|
|
||
| def _install_fake_pyserial(monkeypatch, comports, serial_cls=None): | ||
| """Put a fake `serial` package in sys.modules; pyserial is optional.""" | ||
| list_ports = types.ModuleType("serial.tools.list_ports") | ||
| list_ports.comports = lambda: comports | ||
| tools = types.ModuleType("serial.tools") | ||
| tools.list_ports = list_ports | ||
| serial = types.ModuleType("serial") | ||
| serial.tools = tools | ||
| if serial_cls is not None: | ||
| serial.Serial = serial_cls | ||
| monkeypatch.setitem(sys.modules, "serial", serial) | ||
| monkeypatch.setitem(sys.modules, "serial.tools", tools) | ||
| monkeypatch.setitem(sys.modules, "serial.tools.list_ports", list_ports) | ||
|
|
||
|
|
||
| def _remove_pyserial(monkeypatch): | ||
| # A None entry in sys.modules makes the import raise ImportError. | ||
| for name in ("serial", "serial.tools", "serial.tools.list_ports"): | ||
| monkeypatch.setitem(sys.modules, name, None) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def no_posix_ports(monkeypatch): | ||
| monkeypatch.setattr(commands.glob, "glob", lambda pattern: []) | ||
|
|
||
|
|
||
| def test_com_ports_keep_only_usb_devices(monkeypatch, no_posix_ports): | ||
| _install_fake_pyserial(monkeypatch, [ | ||
| _port("COM1", None), # built-in port | ||
| _port("COM4", None), # Bluetooth serial port | ||
| _port("COM3", 0x10C4), | ||
| ]) | ||
| assert _serial_ports() == ["COM3"] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ Low · Tests depend on a private helper When the serial-port unit tests run, the new tests call Verify these behaviors through the public Also at Prompt for AI agentsIn `tests/unit/cli/test_serial_ports.py` at line 77:
**Tests depend on a private helper**
When the serial-port unit tests run, the new tests call `_serial_ports()` directly to verify filtering and ordering instead of exercising the public monitor command, so those behaviors are coupled to a private implementation detail.
Verify these behaviors through the public `monitor` command rather than calling `_serial_ports()` directly.
Also at `tests/unit/cli/test_serial_ports.py:86`, `tests/unit/cli/test_serial_ports.py:96`, `tests/unit/cli/test_serial_ports.py:105`.From Test behaviour through the public interface, not private internals. |
||
|
|
||
|
|
||
| def test_com_ports_sort_numerically(monkeypatch, no_posix_ports): | ||
| _install_fake_pyserial(monkeypatch, [ | ||
| _port("COM10", 0x0403), | ||
| _port("COM2", 0x0403), | ||
| _port("COM3", 0x0403), | ||
| ]) | ||
| assert _serial_ports() == ["COM2", "COM3", "COM10"] | ||
|
|
||
|
|
||
| def test_non_com_devices_from_pyserial_are_ignored(monkeypatch): | ||
| # On Linux pyserial reports /dev/ttyUSB0 too; the glob already found it. | ||
| monkeypatch.setattr( | ||
| commands.glob, "glob", | ||
| lambda pattern: ["/dev/ttyUSB0"] if pattern == "/dev/ttyUSB*" else [], | ||
| ) | ||
| _install_fake_pyserial(monkeypatch, [_port("/dev/ttyUSB0", 0x10C4)]) | ||
| assert _serial_ports() == ["/dev/ttyUSB0"] | ||
|
|
||
|
|
||
| def test_posix_ports_found_without_pyserial(monkeypatch): | ||
| monkeypatch.setattr( | ||
| commands.glob, "glob", | ||
| lambda pattern: ["/dev/ttyACM0"] if pattern == "/dev/ttyACM*" else [], | ||
| ) | ||
| _remove_pyserial(monkeypatch) | ||
| assert _serial_ports() == ["/dev/ttyACM0"] | ||
|
|
||
|
|
||
| def test_monitor_reports_missing_pyserial_before_detection( | ||
| monkeypatch, no_posix_ports): | ||
| # The before-detection check is Windows-only: on POSIX, glob detection | ||
| # does not need pyserial (see the #83 contract tests in | ||
| # tests/unit/test_monitor_command.py). | ||
| monkeypatch.setattr(sys, "platform", "win32") | ||
| _remove_pyserial(monkeypatch) | ||
| log = RecordingLogger() | ||
| result = CliRunner().invoke(monitor, [], obj=log) | ||
| assert result.exit_code == 1 | ||
| assert len(log.errors) == 1 | ||
| assert "pyserial is not installed" in log.errors[0] | ||
|
|
||
|
|
||
| def test_monitor_auto_detects_single_usb_com_port( | ||
| monkeypatch, no_posix_ports): | ||
| opened = [] | ||
|
|
||
| class FakeSerial: | ||
| def __init__(self, port, baud, timeout): | ||
| opened.append(port) | ||
|
|
||
| def __enter__(self): | ||
| return self | ||
|
|
||
| def __exit__(self, *exc): | ||
| return False | ||
|
|
||
| def read(self, size): | ||
| raise KeyboardInterrupt | ||
|
|
||
| _install_fake_pyserial( | ||
| monkeypatch, | ||
| [_port("COM1", None), _port("COM5", 0x2341)], | ||
| serial_cls=FakeSerial, | ||
| ) | ||
| log = RecordingLogger() | ||
| result = CliRunner().invoke(monitor, [], obj=log) | ||
| assert result.exit_code == 0, log.errors | ||
| assert opened == ["COM5"] | ||
| assert "Auto-detected COM5" in log.infos | ||
|
|
||
|
|
||
| def test_monitor_refuses_to_guess_between_usb_com_ports( | ||
| monkeypatch, no_posix_ports): | ||
| _install_fake_pyserial( | ||
| monkeypatch, [_port("COM3", 0x10C4), _port("COM7", 0x0403)]) | ||
| log = RecordingLogger() | ||
| result = CliRunner().invoke(monitor, [], obj=log) | ||
| assert result.exit_code == 1 | ||
| assert "More than one serial device" in log.errors[0] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| """ | ||
|
|
||
| import glob | ||
| import importlib.util | ||
| import sys | ||
|
|
||
| import pytest | ||
|
|
@@ -39,12 +40,24 @@ def test_monitor_is_a_command(self): | |
|
|
||
|
|
||
| class TestMonitorWithoutHardware: | ||
| @pytest.mark.skipif( | ||
| sys.platform.startswith("win") and importlib.util.find_spec("serial") is None, | ||
| reason="on Windows without pyserial, COM ports cannot be enumerated at all, " | ||
| "so 'no device present' cannot be established \u2014 the monitor correctly " | ||
| "reports the missing pyserial first (covered by test_serial_ports.py)", | ||
| ) | ||
|
Comment on lines
+43
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. A windows test can open a real board The no_serial_devices fixture patches only glob.glob, while the new Windows skip condition checks only whether pyserial is installed. On Windows with pyserial and an attached USB COM device, _serial_ports() finds that device, causing the test to open the real port and enter the monitor loop instead of reporting no device. Agent Prompt
|
||
| def test_no_device_fails_cleanly(self, no_serial_devices): | ||
| result = invoke() | ||
| assert result.exit_code == 1 | ||
| assert "No serial device found" in result.output | ||
| assert "--port" in result.output | ||
|
|
||
| @pytest.mark.skipif( | ||
| sys.platform.startswith("win") and importlib.util.find_spec("serial") is None, | ||
| reason="on Windows without pyserial, COM ports cannot be enumerated at all, " | ||
| "so 'no device present' cannot be established \u2014 the monitor correctly " | ||
| "reports the missing pyserial first (covered by test_serial_ports.py)", | ||
| ) | ||
| def test_multiple_devices_refuses_to_guess(self, monkeypatch): | ||
| monkeypatch.setattr( | ||
| glob, "glob", lambda pattern: ["/dev/ttyUSB0", "/dev/ttyUSB1"] | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.