mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-19 03:53:37 +03:00
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
308 lines
13 KiB
Python
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
|