Files
Peter Dragun 19155d0821 fix(tools): Run idf.py monitor from the directory idf.py was invoked in
The monitor is handed the idf.py command line to re-run on a rebuild or flash
request, taken as given up to the first action, but ran in the project
directory. Any relative path in that command line, such as -C, -B or an @file
argument, then resolved against the wrong directory: with app/args holding
`-C app`, `idf.py @app/args monitor` failed with "could not be opened" when
the monitor tried to rebuild, looking for app/app/args.

Run the monitor from the invocation directory instead, and pin the project
and build directory it is attached to on the re-run command. Otherwise a -C
given after an action (`idf.py monitor -C proj`) or the container project of
`idf.py build-file` is lost, and the re-run would target the invocation
directory. Pin the resolved project and build directories at the end of
the re-run command, so they win over any -C/-B given earlier
(needed for build-file, which replaces them with its container project).

The monitor log file (Ctrl+T Ctrl+L) is now created in the invocation
directory; the docs are updated accordingly.

Closes https://github.com/espressif/esp-idf/pull/19184
2026-10-12 14:45:56 +02:00

308 lines
13 KiB
Python

# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import asyncio
import json
import os
import pathlib
import shlex
import sys
from typing import Any
from unittest import mock
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import idf # noqa: E402
from idf_py_actions.build_file_ext import _compute_project_hash # noqa: E402
from idf_py_actions.tools import PropertyDict # noqa: E402
from idf_py_actions.tools import RunTool # noqa: E402
from idf_py_actions.tools import get_selected_target # noqa: E402
@pytest.fixture
def project_dir(tmp_path: pathlib.Path) -> str:
return str(tmp_path)
@pytest.fixture
def args(project_dir: str, tmp_path: pathlib.Path) -> PropertyDict:
build_dir = str(tmp_path / 'build')
os.makedirs(build_dir, exist_ok=True)
return PropertyDict(
project_dir=project_dir,
build_dir=build_dir,
port=None,
baud=460800,
define_cache_entry=[],
no_hints=False,
)
class TestGetSelectedTarget:
def test_returns_none_when_nothing_set(self, args: PropertyDict) -> None:
with mock.patch.dict(os.environ, {}, clear=True):
os.environ.pop('IDF_TARGET', None)
os.environ.pop('SDKCONFIG_DEFAULTS', None)
assert get_selected_target(args) is None
def test_returns_target_from_env(self, args: PropertyDict) -> None:
with mock.patch.dict(os.environ, {'IDF_TARGET': 'esp32s3'}):
assert get_selected_target(args) == 'esp32s3'
def test_returns_target_from_define_cache(self, args: PropertyDict) -> None:
args.define_cache_entry = ['IDF_TARGET=esp32c3']
with mock.patch.dict(os.environ, {}, clear=True):
os.environ.pop('IDF_TARGET', None)
os.environ.pop('SDKCONFIG_DEFAULTS', None)
assert get_selected_target(args) == 'esp32c3'
def test_returns_target_from_sdkconfig(self, args: PropertyDict, project_dir: str) -> None:
sdkconfig_path = os.path.join(project_dir, 'sdkconfig')
with open(sdkconfig_path, 'w') as f:
f.write('CONFIG_IDF_TARGET="esp32h2"\n')
with mock.patch.dict(os.environ, {}, clear=True):
os.environ.pop('IDF_TARGET', None)
os.environ.pop('SDKCONFIG_DEFAULTS', None)
assert get_selected_target(args) == 'esp32h2'
def test_returns_target_from_sdkconfig_defaults(self, args: PropertyDict, project_dir: str) -> None:
defaults_path = os.path.join(project_dir, 'sdkconfig.defaults')
with open(defaults_path, 'w') as f:
f.write('CONFIG_IDF_TARGET="esp32c6"\n')
with mock.patch.dict(os.environ, {}, clear=True):
os.environ.pop('IDF_TARGET', None)
os.environ.pop('SDKCONFIG_DEFAULTS', None)
assert get_selected_target(args) == 'esp32c6'
def test_returns_target_from_cmake_cache(self, args: PropertyDict) -> None:
cache_path = os.path.join(args.build_dir, 'CMakeCache.txt')
with open(cache_path, 'w') as f:
f.write('IDF_TARGET:STRING=esp32s2\n')
with mock.patch.dict(os.environ, {}, clear=True):
os.environ.pop('IDF_TARGET', None)
os.environ.pop('SDKCONFIG_DEFAULTS', None)
assert get_selected_target(args) == 'esp32s2'
def test_env_takes_priority_over_cache(self, args: PropertyDict) -> None:
cache_path = os.path.join(args.build_dir, 'CMakeCache.txt')
with open(cache_path, 'w') as f:
f.write('IDF_TARGET:STRING=esp32s2\n')
with mock.patch.dict(os.environ, {'IDF_TARGET': 'esp32c3'}):
assert get_selected_target(args) == 'esp32c3'
class TestMonitorPortDetection:
"""Test that the monitor function builds correct args for idf_monitor."""
@pytest.fixture
def mock_esp(self) -> Any:
esp = mock.MagicMock()
esp.serial_port = '/dev/ttyUSB0'
esp.CHIP_NAME = 'ESP32-C5'
return esp
def _get_monitor_fn(self) -> Any:
from idf_py_actions.serial_ext import action_extensions
ext = action_extensions({}, '')
return ext['actions']['monitor']['callback']
def _call_monitor(
self, args: PropertyDict, port: str | None = None, no_reset: bool = False, command_file: str | None = None
) -> None:
args.port = port
monitor_fn = self._get_monitor_fn()
ctx = mock.MagicMock()
ctx.info_name = 'idf.py'
ctx._parameter_source = {'baud': mock.MagicMock()}
monitor_fn(
'monitor',
ctx,
args,
print_filter=None,
monitor_baud=None,
encrypted=False,
no_reset=no_reset,
timestamps=False,
timestamp_format=None,
force_color=False,
disable_auto_color=False,
command_file=command_file,
)
def test_clean_project_autodetects_port_and_target(self, args: PropertyDict, mock_esp: Any) -> None:
with mock.patch.dict(os.environ, {'IDF_PATH': '/idf'}, clear=False):
os.environ.pop('IDF_TARGET', None)
with mock.patch('idf_py_actions.serial_ext.get_default_esp', return_value=mock_esp):
with mock.patch('idf_py_actions.serial_ext.RunTool') as mock_run:
self._call_monitor(args)
call_args = mock_run.call_args[0][1]
assert '-p' in call_args
assert '/dev/ttyUSB0' in call_args
assert '--target' in call_args
assert 'esp32c5' in call_args
def test_clean_project_with_explicit_port(self, args: PropertyDict) -> None:
with mock.patch.dict(os.environ, {'IDF_PATH': '/idf'}, clear=False):
os.environ.pop('IDF_TARGET', None)
with mock.patch('idf_py_actions.serial_ext.get_default_serial_port', return_value='/dev/ttyACM0'):
with mock.patch('idf_py_actions.serial_ext.RunTool') as mock_run:
self._call_monitor(args, port='/dev/ttyACM1')
call_args = mock_run.call_args[0][1]
assert '/dev/ttyACM1' in call_args
def test_clean_project_with_target_set_uses_correct_port(self, args: PropertyDict) -> None:
with mock.patch.dict(os.environ, {'IDF_PATH': '/idf', 'IDF_TARGET': 'esp32s3'}):
with mock.patch(
'idf_py_actions.serial_ext.get_default_serial_port', return_value='/dev/ttyUSB2'
) as mock_port:
with mock.patch('idf_py_actions.serial_ext.RunTool') as mock_run:
self._call_monitor(args)
mock_port.assert_called_with('esp32s3')
call_args = mock_run.call_args[0][1]
assert '/dev/ttyUSB2' in call_args
assert '--target' in call_args
assert 'esp32s3' in call_args
def test_clean_project_with_target_set_no_autodetect(self, args: PropertyDict, mock_esp: Any) -> None:
"""When target is already set, get_default_esp should NOT be called."""
with mock.patch.dict(os.environ, {'IDF_PATH': '/idf', 'IDF_TARGET': 'esp32s3'}):
with mock.patch('idf_py_actions.serial_ext.get_default_esp', return_value=mock_esp) as mock_detect:
with mock.patch('idf_py_actions.serial_ext.get_default_serial_port', return_value='/dev/ttyUSB0'):
with mock.patch('idf_py_actions.serial_ext.RunTool'):
self._call_monitor(args)
mock_detect.assert_not_called()
def test_command_file_redirects_stdin_to_runtool(self, args: PropertyDict, tmp_path: pathlib.Path) -> None:
"""--command-file FILE must open the file and pass it as RunTool stdin."""
command_file_path = tmp_path / 'monitor_commands.txt'
command_file_path.write_text('reset\nexpect Hello\n')
with mock.patch.dict(os.environ, {'IDF_PATH': '/idf', 'IDF_TARGET': 'esp32s3'}):
with mock.patch('idf_py_actions.serial_ext.get_default_serial_port', return_value='/dev/ttyUSB0'):
with mock.patch('idf_py_actions.serial_ext.RunTool') as mock_run:
self._call_monitor(args, command_file=str(command_file_path))
assert mock_run.called
stdin = mock_run.call_args.kwargs.get('stdin')
assert stdin is not None
assert stdin.name == str(command_file_path)
# File should be closed after RunTool returns
assert stdin.closed
def _make_built_project(project_dir: pathlib.Path) -> None:
"""Create a project built for the linux target, so that the monitor needs no serial port."""
(project_dir / 'build').mkdir(parents=True)
(project_dir / 'CMakeLists.txt').write_text('')
project_desc = {'target': 'linux', 'app_elf': 'app.elf', 'monitor_toolprefix': '', 'config_file': ''}
(project_dir / 'build' / 'project_description.json').write_text(json.dumps(project_desc))
class TestMonitorRerunCommand:
"""Run idf.py with the monitor and check the idf.py command handed to it (-m) to re-run on a rebuild
or flash request: the options before the first action as given, followed by the project the monitor
is attached to. The monitor runs in the invocation directory, which these options are relative to.
"""
@pytest.fixture
def work_dir(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
"""Invocation directory with project `proj`, @file `proj/args` and `blink.c` for build-file."""
# init_cli() imports the extensions as top-level modules, so patch the build_file_ext it uses
monkeypatch.syspath_prepend(os.path.join(os.environ['IDF_PATH'], 'tools', 'idf_py_actions'))
monkeypatch.setattr('build_file_ext._BUILD_FILE_CACHE_DIR', str(tmp_path))
monkeypatch.chdir(tmp_path)
_make_built_project(tmp_path / 'proj')
(tmp_path / 'proj' / 'args').write_text('-C proj\n')
(tmp_path / 'blink.c').write_text('')
_make_built_project(self._container_dir(tmp_path))
return tmp_path
@staticmethod
def _container_dir(work_dir: pathlib.Path) -> pathlib.Path:
return work_dir / f'blink_{_compute_project_hash(str(work_dir / "blink.c"))}'
@pytest.mark.parametrize(
'argv, options, project',
[
pytest.param(['-C', 'proj', 'monitor'], ['-C', 'proj'], 'proj', id='project-dir'),
pytest.param(['monitor', '-C', 'proj'], [], 'proj', id='project-dir-after-action'),
pytest.param(['flash', 'monitor', '-C', 'proj'], [], 'proj', id='project-dir-after-actions'),
pytest.param(['@proj/args', 'monitor'], ['@proj/args'], 'proj', id='argfile'),
pytest.param(['build-file', 'blink.c', 'flash', 'monitor'], [], 'container', id='build-file'),
pytest.param(
['-B', 'out', 'build-file', 'blink.c', 'flash', 'monitor'],
['-B', 'out'],
'container',
id='build-file-build-dir',
),
pytest.param(
['-C', 'proj', 'build-file', 'blink.c', 'flash', 'monitor'],
['-C', 'proj'],
'container',
id='build-file-project-dir',
),
],
)
def test_rerun_command(self, work_dir: pathlib.Path, argv: list[str], options: list[str], project: str) -> None:
project_dir = work_dir / 'proj' if project == 'proj' else self._container_dir(work_dir)
with mock.patch.object(sys, 'argv', ['idf.py', *argv]):
with mock.patch.object(RunTool, '__call__', autospec=True) as run_tool:
idf.init_cli()(idf.expand_file_arguments(argv), prog_name='idf.py', standalone_mode=False)
(monitor,) = (call.args[0] for call in run_tool.call_args_list if call.args[0].tool_name == 'idf_monitor')
rerun_cmd = shlex.split(monitor.args[monitor.args.index('-m') + 1])
# A -C/-B given before the first action must not override the pinned project, so it comes last
assert rerun_cmd == [
sys.executable,
'idf.py',
*options,
'-C',
str(project_dir),
'-B',
str(project_dir / 'build'),
]
assert monitor.cwd == str(work_dir)
class TestRunToolStdin:
def test_forwards_stdin_without_hints(self, tmp_path: pathlib.Path) -> None:
script_path = tmp_path / 'monitor_commands.txt'
script_path.write_text('exit\n')
with script_path.open('rb') as script_file:
tool = RunTool('test', ['command'], str(tmp_path), hints=False, stdin=script_file)
with mock.patch('idf_py_actions.tools.subprocess.run', return_value=mock.Mock(returncode=0)) as mock_run:
tool()
assert mock_run.call_args.kwargs['stdin'] is script_file
def test_forwards_stdin_with_hints(self, tmp_path: pathlib.Path) -> None:
script_path = tmp_path / 'monitor_commands.txt'
script_path.write_text('exit\n')
process = mock.MagicMock()
process.pid = 123
process.stdout = None
process.stderr = None
process.wait = mock.AsyncMock()
with script_path.open('rb') as script_file:
tool = RunTool('test', ['command'], str(tmp_path), build_dir=str(tmp_path), stdin=script_file)
with mock.patch(
'idf_py_actions.tools.asyncio.create_subprocess_exec',
new_callable=mock.AsyncMock,
return_value=process,
) as mock_exec:
asyncio.run(tool.run_command(['command'], {}))
assert mock_exec.call_args.kwargs['stdin'] is script_file