From 0c1aa940ff9369b0004fa1d0ff2b206426d55bcf Mon Sep 17 00:00:00 2001 From: Shubham Patil Date: Tue, 11 Aug 2026 13:01:49 +0530 Subject: [PATCH 1/4] feat: add pytest-embedded-espemu service for esp-emu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an 'espemu' service that runs tests on esp-emu (https://github.com/espressif/esp-emulator), Espressif's emulator for ESP RISC-V series SoCs, instead of a real target: pytest --embedded-services idf,espemu --target esp32c3 EspEmuApp merges the app's flash files into a single image (esptool merge-bin, no padding needed) and EspEmu launches esp-emu with UART0 on stdio, so DUT I/O rides the standard DuplicateStdoutPopen redirection — no serial ports or TCP bridges involved. hard_reset raises NotImplementedError, which IdfUnityDutMixin already handles by re-triggering the Unity test menu. Supported targets: esp32c3, esp32c6, esp32h2, esp32p4, esp32s31. Extra emulator flags pass through --espemu-cli-args/--espemu-extra-args (e.g. '--net user,restrict=yes' to isolate tests from LAN noise). --- README.md | 2 + all_local_packages.txt | 1 + foreach.sh | 1 + pyproject.toml | 2 + pytest-embedded-espemu/LICENSE | 21 +++++ pytest-embedded-espemu/README.md | 19 +++++ pytest-embedded-espemu/pyproject.toml | 44 +++++++++++ .../pytest_embedded_espemu/__init__.py | 31 ++++++++ .../pytest_embedded_espemu/app.py | 67 ++++++++++++++++ .../pytest_embedded_espemu/dut.py | 25 ++++++ .../pytest_embedded_espemu/espemu.py | 76 +++++++++++++++++++ .../pytest_embedded/dut_factory.py | 76 ++++++++++++++++++- pytest-embedded/pytest_embedded/plugin.py | 64 ++++++++++++++++ pytest-embedded/pytest_embedded/utils.py | 6 +- 14 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 pytest-embedded-espemu/LICENSE create mode 100644 pytest-embedded-espemu/README.md create mode 100644 pytest-embedded-espemu/pyproject.toml create mode 100644 pytest-embedded-espemu/pytest_embedded_espemu/__init__.py create mode 100644 pytest-embedded-espemu/pytest_embedded_espemu/app.py create mode 100644 pytest-embedded-espemu/pytest_embedded_espemu/dut.py create mode 100644 pytest-embedded-espemu/pytest_embedded_espemu/espemu.py diff --git a/README.md b/README.md index 0fa1f3c6..c642c6a3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A pytest plugin that has multiple services available for various functionalities [![pytest-embedded-serial-esp](https://img.shields.io/pypi/v/pytest-embedded-serial-esp?color=green&label=pytest-embedded-serial-esp)](https://pypi.org/project/pytest-embedded-serial-esp/) [![pytest-embedded-idf](https://img.shields.io/pypi/v/pytest-embedded-idf?color=green&label=pytest-embedded-idf)](https://pypi.org/project/pytest-embedded-idf/) [![pytest-embedded-qemu](https://img.shields.io/pypi/v/pytest-embedded-qemu?color=green&label=pytest-embedded-qemu)](https://pypi.org/project/pytest-embedded-qemu/) +[![pytest-embedded-espemu](https://img.shields.io/pypi/v/pytest-embedded-espemu?color=green&label=pytest-embedded-espemu)](https://pypi.org/project/pytest-embedded-espemu/) [![pytest-embedded-arduino](https://img.shields.io/pypi/v/pytest-embedded-arduino?color=green&label=pytest-embedded-arduino)](https://pypi.org/project/pytest-embedded-arduino/) [![pytest-embedded-wokwi](https://img.shields.io/pypi/v/pytest-embedded-wokwi?color=green&label=pytest-embedded-wokwi)](https://pypi.org/project/pytest-embedded-wokwi/) [![pytest-embedded-nuttx](https://img.shields.io/pypi/v/pytest-embedded-nuttx?color=green&label=pytest-embedded-nuttx)](https://pypi.org/project/pytest-embedded-nuttx/) @@ -76,6 +77,7 @@ Available services: - `idf`: auto-detect more app info with [ESP-IDF](https://github.com/espressif/esp-idf) specific rules, auto-flash the binary into the target. - `jtag`: openocd/gdb utilities - `qemu`: running test cases on QEMU instead of the real target. +- `espemu`: running test cases on [esp-emu](https://github.com/espressif/esp-emulator) instead of the real target. - `arduino`: auto-detect more app info with [arduino](https://github.com/arduino/Arduino) specific rules, auto-flash the binary into the target. - `wokwi`: running test cases with [Wokwi](https://wokwi.com/) instead of the real target. - `nuttx`: service for [nuttx](https://nuttx.apache.org/) project, optionally with espressif devices. diff --git a/all_local_packages.txt b/all_local_packages.txt index ad644e06..2c1959e4 100644 --- a/all_local_packages.txt +++ b/all_local_packages.txt @@ -4,6 +4,7 @@ -e ./pytest-embedded-idf/ -e ./pytest-embedded-jtag/ -e ./pytest-embedded-qemu/ +-e ./pytest-embedded-espemu/ -e ./pytest-embedded-arduino/ -e ./pytest-embedded-nuttx/ -e ./pytest-embedded-wokwi/ diff --git a/foreach.sh b/foreach.sh index 74c92356..9300c2fa 100755 --- a/foreach.sh +++ b/foreach.sh @@ -9,6 +9,7 @@ DEFAULT_PACKAGES=" \ pytest-embedded-idf \ pytest-embedded-jtag \ pytest-embedded-qemu \ + pytest-embedded-espemu \ pytest-embedded-arduino \ pytest-embedded-wokwi \ pytest-embedded-nuttx \ diff --git a/pyproject.toml b/pyproject.toml index 86f9078e..1d0507a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ version_files = [ "pytest-embedded-jtag/pyproject.toml", "pytest-embedded-qemu/pytest_embedded_qemu/__init__.py", "pytest-embedded-qemu/pyproject.toml", + "pytest-embedded-espemu/pytest_embedded_espemu/__init__.py", + "pytest-embedded-espemu/pyproject.toml", "pytest-embedded-serial/pytest_embedded_serial/__init__.py", "pytest-embedded-serial/pyproject.toml", "pytest-embedded-serial-esp/pytest_embedded_serial_esp/__init__.py", diff --git a/pytest-embedded-espemu/LICENSE b/pytest-embedded-espemu/LICENSE new file mode 100644 index 00000000..42649c4b --- /dev/null +++ b/pytest-embedded-espemu/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Espressif Systems (Shanghai) Co. Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pytest-embedded-espemu/README.md b/pytest-embedded-espemu/README.md new file mode 100644 index 00000000..e32d22b8 --- /dev/null +++ b/pytest-embedded-espemu/README.md @@ -0,0 +1,19 @@ +### pytest-embedded-espemu + +pytest-embedded service for running tests on [esp-emu](https://github.com/espressif/esp-emulator), Espressif's lightweight emulator for ESP RISC-V series SoCs, instead of real targets. + +Supported targets: `esp32c3`, `esp32c6`, `esp32h2`, `esp32p4`, `esp32s31`. + +#### Usage + +```shell +pytest --embedded-services idf,espemu --target esp32c3 +``` + +The service builds a merged flash binary from the app's build directory (via `esptool merge-bin`), launches `esp-emu` with UART0 on stdio, and drives it like any other DUT — including the Unity test menu machinery from `pytest-embedded-idf`. + +Extra CLI options: + +- `--espemu-image-path`: use an existing merged flash binary instead of generating one +- `--espemu-prog-path`: path to the `esp-emu` binary (default: `esp-emu` from `PATH`) +- `--espemu-cli-args` / `--espemu-extra-args`: forwarded to the `esp-emu` command line, e.g. `--espemu-extra-args "--net user,restrict=yes"` diff --git a/pytest-embedded-espemu/pyproject.toml b/pytest-embedded-espemu/pyproject.toml new file mode 100644 index 00000000..8662fc36 --- /dev/null +++ b/pytest-embedded-espemu/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "pytest-embedded-espemu" +authors = [ + {name = "Espressif Systems"}, +] +readme = "README.md" +license = {file = "LICENSE"} +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Pytest", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python", + "Topic :: Software Development :: Testing", +] +dynamic = ["version", "description"] +requires-python = ">=3.10" + +dependencies = [ + "pytest-embedded~=2.8.1", +] + +[project.optional-dependencies] +idf = [ + "pytest-embedded-idf~=2.8.1", +] + +[project.urls] +homepage = "https://github.com/espressif/pytest-embedded" +repository = "https://github.com/espressif/pytest-embedded" +documentation = "https://docs.espressif.com/projects/pytest-embedded/en/latest/" +changelog = "https://github.com/espressif/pytest-embedded/blob/main/CHANGELOG.md" diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/__init__.py b/pytest-embedded-espemu/pytest_embedded_espemu/__init__.py new file mode 100644 index 00000000..e45767c8 --- /dev/null +++ b/pytest-embedded-espemu/pytest_embedded_espemu/__init__.py @@ -0,0 +1,31 @@ +"""Make pytest-embedded plugin work with esp-emu.""" + +import importlib + +from pytest_embedded.utils import lazy_load + +DEFAULT_IMAGE_FN = 'espemu_image.bin' + +from .dut import EspEmuDut # noqa +from .espemu import EspEmu # noqa + +__getattr__ = lazy_load( + importlib.import_module(__name__), + { + 'EspEmu': EspEmu, + 'EspEmuDut': EspEmuDut, + }, + { + 'EspEmuApp': '.app', # requires idf + }, +) + + +__all__ = [ + 'DEFAULT_IMAGE_FN', + 'EspEmu', + 'EspEmuApp', + 'EspEmuDut', +] + +__version__ = '2.8.1' diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/app.py b/pytest-embedded-espemu/pytest_embedded_espemu/app.py new file mode 100644 index 00000000..c1d230be --- /dev/null +++ b/pytest-embedded-espemu/pytest_embedded_espemu/app.py @@ -0,0 +1,67 @@ +import contextlib +import logging +import os +import sys + +from pytest_embedded.log import MessageQueue, live_print_call +from pytest_embedded_idf.app import IdfApp + +from . import DEFAULT_IMAGE_FN + + +class EspEmuApp(IdfApp): + """ + esp-emu App class + + Attributes: + image_path (str): esp-emu flash-able bin path + """ + + def __init__( + self, + msg_queue: MessageQueue, + espemu_image_path: str | None = None, + skip_regenerate_image: bool | None = False, + **kwargs, + ): + self._q = msg_queue + + super().__init__(**kwargs) + + self.image_path = espemu_image_path or os.path.join(self.binary_path, DEFAULT_IMAGE_FN) + self.skip_regenerate_image = skip_regenerate_image + + self.create_image() + + def create_image(self) -> None: + """ + Create the image, if it doesn't exist. + """ + if os.path.exists(self.image_path) and self.skip_regenerate_image: + logging.info(f'Using existing image: {self.image_path}') + return + + try: + import esptool # noqa + except ImportError: + raise ImportError( + 'esptool is required for creating esp-emu images. ' + 'Please install esptool with "pip install -U esptool" or use an existing image.' + ) + + # esp-emu accepts a plain merged flash binary, no flash-size padding needed + with contextlib.redirect_stdout(self._q): + live_print_call( + [ + sys.executable, + '-m', + 'esptool', + '--chip', + self.target, + 'merge-bin', + '-o', + self.image_path, + *self.write_flash_args, + ], + cwd=self.binary_path, + ) diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/dut.py b/pytest-embedded-espemu/pytest_embedded_espemu/dut.py new file mode 100644 index 00000000..3c68dd2c --- /dev/null +++ b/pytest-embedded-espemu/pytest_embedded_espemu/dut.py @@ -0,0 +1,25 @@ +from typing import AnyStr + +from pytest_embedded.dut import Dut + +from .espemu import EspEmu + + +class EspEmuDut(Dut): + """ + esp-emu dut class + """ + + def __init__( + self, + espemu: EspEmu, + **kwargs, + ) -> None: + self.espemu = espemu + + super().__init__(**kwargs) + + self._hard_reset_func = self.espemu._hard_reset + + def write(self, s: AnyStr) -> None: + self.espemu.write(s) diff --git a/pytest-embedded-espemu/pytest_embedded_espemu/espemu.py b/pytest-embedded-espemu/pytest_embedded_espemu/espemu.py new file mode 100644 index 00000000..0b973884 --- /dev/null +++ b/pytest-embedded-espemu/pytest_embedded_espemu/espemu.py @@ -0,0 +1,76 @@ +import shlex +import typing as t + +from pytest_embedded.log import DuplicateStdoutPopen + +if t.TYPE_CHECKING: + from .app import EspEmuApp + + +class EspEmu(DuplicateStdoutPopen): + """ + esp-emu class (https://github.com/espressif/esp-emulator) + + The emulator runs with UART0 attached to stdio: its output streams + straight into the pexpect process and `write()` feeds the firmware's + UART RX via stdin. No sockets are involved. + """ + + SOURCE = 'ESPEMU' + + ESPEMU_PROG_PATH = 'esp-emu' + + SUPPORTED_TARGETS: t.ClassVar[tuple] = ('esp32c3', 'esp32c6', 'esp32h2', 'esp32p4', 'esp32s31') + + def __init__( + self, + espemu_image_path: str | None = None, + espemu_prog_path: str | None = None, + espemu_cli_args: str | None = None, + espemu_extra_args: str | None = None, + app: t.Optional['EspEmuApp'] = None, + **kwargs, + ): + """ + Args: + espemu_image_path: image path (merged flash binary) + espemu_prog_path: esp-emu program path + espemu_cli_args: esp-emu CLI arguments + espemu_extra_args: esp-emu CLI extra arguments, will be appended to `espemu_cli_args` + app: `EspEmuApp` instance, used to detect the target chip + """ + self.app = app + + image_path = espemu_image_path + if not image_path and self.app: + image_path = self.app.image_path + if not image_path: + raise ValueError('Please specify --espemu-image-path or use the espemu service together with idf') + + target = getattr(self.app, 'target', None) + if target not in self.SUPPORTED_TARGETS: + raise ValueError( + f'esp-emu does not support target {target!r}. Supported targets: {", ".join(self.SUPPORTED_TARGETS)}' + ) + + espemu_prog_path = espemu_prog_path or self.ESPEMU_PROG_PATH + + cmd = [ + espemu_prog_path, + '--chip', + target, + '--firmware', + image_path, + *shlex.split(espemu_cli_args or ''), + *shlex.split(espemu_extra_args or ''), + ] + + super().__init__(cmd=cmd, **kwargs) + + def _hard_reset(self): + """ + esp-emu has no reset API. Raising `NotImplementedError` makes + `IdfUnityDutMixin` fall back to re-triggering the Unity test menu + with a newline instead of resetting the target. + """ + raise NotImplementedError('esp-emu does not support resetting; relaunch the emulator instead') diff --git a/pytest-embedded/pytest_embedded/dut_factory.py b/pytest-embedded/pytest_embedded/dut_factory.py index 3c17b4a2..37e273d1 100644 --- a/pytest-embedded/pytest_embedded/dut_factory.py +++ b/pytest-embedded/pytest_embedded/dut_factory.py @@ -13,6 +13,7 @@ from pathlib import Path if t.TYPE_CHECKING: + from pytest_embedded_espemu import EspEmu from pytest_embedded_idf import LinuxSerial from pytest_embedded_idf.dut import IdfDut from pytest_embedded_jtag import Gdb, OpenOcd @@ -157,6 +158,10 @@ def _fixture_classes_and_options_fn( qemu_cli_args, qemu_extra_args, qemu_efuse_path, + espemu_image_path, + espemu_prog_path, + espemu_cli_args, + espemu_extra_args, wokwi_diagram, wokwi_usb_serial_jtag, skip_regenerate_image, @@ -195,6 +200,18 @@ def _fixture_classes_and_options_fn( 'qemu_efuse_path': qemu_efuse_path, } ) + elif 'espemu' in _services: + from pytest_embedded_espemu import EspEmuApp + + classes[fixture] = EspEmuApp + kwargs[fixture].update( + { + 'msg_queue': msg_queue, + 'part_tool': part_tool, + 'espemu_image_path': espemu_image_path, + 'skip_regenerate_image': skip_regenerate_image, + } + ) else: from pytest_embedded_idf import IdfApp @@ -330,6 +347,24 @@ def _fixture_classes_and_options_fn( 'meta': _meta, 'dut_index': dut_index, } + elif fixture == 'espemu': + if 'espemu' in _services: + from pytest_embedded_espemu import ( + DEFAULT_IMAGE_FN, + EspEmu, + ) + + classes[fixture] = EspEmu + kwargs[fixture] = { + 'msg_queue': msg_queue, + 'espemu_image_path': espemu_image_path + or os.path.join(app_path or '', build_dir or 'build', DEFAULT_IMAGE_FN), + 'espemu_prog_path': espemu_prog_path, + 'espemu_cli_args': espemu_cli_args, + 'espemu_extra_args': espemu_extra_args, + 'app': None, + 'meta': _meta, + } elif fixture == 'wokwi': if 'wokwi' in _services: from pytest_embedded_wokwi import Wokwi @@ -381,6 +416,15 @@ def _fixture_classes_and_options_fn( kwargs['wokwi'].update({'firmware_resolver': ArduinoFirmwareResolver()}) else: raise SystemExit('wokwi service should be used together with idf or arduino service') + elif 'espemu' in _services: + from pytest_embedded_espemu import EspEmuDut + + classes[fixture] = EspEmuDut + kwargs[fixture].update( + { + 'espemu': None, + } + ) elif 'qemu' in _services: if 'nuttx' in _services: from pytest_embedded_nuttx import NuttxQemuDut @@ -534,6 +578,19 @@ def qemu_gn(_fixture_classes_and_options: ClassCliOptions, app) -> t.Optional['Q return cls(**_drop_none_kwargs(kwargs)) +def espemu_gn(_fixture_classes_and_options: ClassCliOptions, app) -> t.Optional['EspEmu']: + if 'espemu' not in _fixture_classes_and_options.classes: + return None + + cls = _fixture_classes_and_options.classes['espemu'] + kwargs = _fixture_classes_and_options.kwargs['espemu'] + + if 'app' in kwargs and kwargs['app'] is None: + kwargs['app'] = app + + return cls(**_drop_none_kwargs(kwargs)) + + def wokwi_gn(_fixture_classes_and_options: ClassCliOptions, app) -> t.Optional['Wokwi']: """A wokwi subprocess that could read/redirect/write""" if 'wokwi' not in _fixture_classes_and_options.classes: @@ -555,6 +612,7 @@ def dut_gn( serial: t.Union['Serial', 'LinuxSerial'] | None, qemu: t.Optional['Qemu'], wokwi: t.Optional['Wokwi'], + espemu: t.Optional['EspEmu'] = None, ) -> Dut | list[Dut]: global DUT_GLOBAL_INDEX DUT_GLOBAL_INDEX += 1 @@ -584,6 +642,8 @@ def dut_gn( kwargs[k] = gdb elif k == 'qemu': kwargs[k] = qemu + elif k == 'espemu': + kwargs[k] = espemu elif k == 'wokwi': kwargs[k] = wokwi return cls(**_drop_none_kwargs(kwargs), mixins=mixins) @@ -700,6 +760,10 @@ def create( qemu_cli_args: str | None = None, qemu_extra_args: str | None = None, qemu_efuse_path: str | None = None, + espemu_image_path: str | None = None, + espemu_prog_path: str | None = None, + espemu_cli_args: str | None = None, + espemu_extra_args: str | None = None, wokwi_diagram: str | None = None, wokwi_usb_serial_jtag: bool | None = None, skip_regenerate_image: bool | None = None, @@ -748,6 +812,10 @@ def create( qemu_cli_args: QEMU CLI arguments. qemu_extra_args: Additional QEMU arguments. qemu_efuse_path: Efuse binary path. + espemu_image_path: esp-emu image path. + espemu_prog_path: esp-emu program path. + espemu_cli_args: esp-emu CLI arguments. + espemu_extra_args: Additional esp-emu arguments. wokwi_diagram: Wokwi diagram path. wokwi_usb_serial_jtag: Use USB Serial JTAG instead of UART for Wokwi serial communication. skip_regenerate_image: Skip image regeneration flag. @@ -820,6 +888,10 @@ def create( 'qemu_cli_args': qemu_cli_args, 'qemu_extra_args': qemu_extra_args, 'qemu_efuse_path': qemu_efuse_path, + 'espemu_image_path': espemu_image_path, + 'espemu_prog_path': espemu_prog_path, + 'espemu_cli_args': espemu_cli_args, + 'espemu_extra_args': espemu_extra_args, 'wokwi_diagram': wokwi_diagram, 'wokwi_usb_serial_jtag': wokwi_usb_serial_jtag, 'skip_regenerate_image': skip_regenerate_image, @@ -850,11 +922,13 @@ def create( qemu = qemu_gn(_fixture_classes_and_options, app) layout.append(qemu) + espemu = espemu_gn(_fixture_classes_and_options, app) + layout.append(espemu) wokwi = wokwi_gn(_fixture_classes_and_options, app) layout.append(wokwi) - dut = dut_gn(_fixture_classes_and_options, openocd, gdb, app, serial, qemu, wokwi) + dut = dut_gn(_fixture_classes_and_options, openocd, gdb, app, serial, qemu, wokwi, espemu) layout.append(dut) cls.obj_stack.append(layout) diff --git a/pytest-embedded/pytest_embedded/plugin.py b/pytest-embedded/pytest_embedded/plugin.py index 2984d9fa..29a12eba 100644 --- a/pytest-embedded/pytest_embedded/plugin.py +++ b/pytest-embedded/pytest_embedded/plugin.py @@ -36,6 +36,7 @@ _pexpect_fr_gn, app_fn, dut_gn, + espemu_gn, gdb_gn, openocd_gn, pexpect_proc_fn, @@ -60,6 +61,7 @@ ) if t.TYPE_CHECKING: + from pytest_embedded_espemu import EspEmu from pytest_embedded_idf import CaseTester, IdfDut, LinuxSerial from pytest_embedded_jtag import Gdb, OpenOcd from pytest_embedded_qemu import Qemu @@ -140,6 +142,7 @@ def pytest_addoption(parser: pytest.Parser): '- idf: auto-detect more app info with idf specific rules, auto flash-in\n' '- jtag: openocd and gdb\n' '- qemu: use qemu simulator instead of the real target\n' + '- espemu: use esp-emu simulator instead of the real target\n' '- arduino: auto-detect more app info with arduino specific rules, auto flash-in\n' '- wokwi: use wokwi simulator instead of the real target\n' '- nuttx: service for nuttx project, optionally with espressif devices\n' @@ -304,6 +307,24 @@ def pytest_addoption(parser: pytest.Parser): help='Flash Encryption (pre-encrypted workflow) key path. (Default: None)', ) + espemu_group = parser.getgroup('embedded-espemu') + espemu_group.addoption( + '--espemu-image-path', + help='esp-emu image path. (Default: "//espemu_image.bin")', + ) + espemu_group.addoption( + '--espemu-prog-path', + help='esp-emu program path. (Default: "esp-emu")', + ) + espemu_group.addoption( + '--espemu-cli-args', + help='esp-emu cli default arguments. (Default: None)', + ) + espemu_group.addoption( + '--espemu-extra-args', + help='esp-emu cli extra arguments, will append to the argument list. (Default: None)', + ) + wokwi_group = parser.getgroup('embedded-wokwi') wokwi_group.addoption( '--wokwi-diagram', @@ -1070,6 +1091,37 @@ def qemu_efuse_path(request: FixtureRequest) -> str | None: return _request_param_or_config_option_or_default(request, 'qemu_efuse_path', None) +########## +# espemu # +########## +@pytest.fixture +@multi_dut_argument +def espemu_image_path(request: FixtureRequest) -> str | None: + """Enable parametrization for the same cli option""" + return _request_param_or_config_option_or_default(request, 'espemu_image_path', None) + + +@pytest.fixture +@multi_dut_argument +def espemu_prog_path(request: FixtureRequest) -> str | None: + """Enable parametrization for the same cli option""" + return _request_param_or_config_option_or_default(request, 'espemu_prog_path', None) + + +@pytest.fixture +@multi_dut_argument +def espemu_cli_args(request: FixtureRequest) -> str | None: + """Enable parametrization for the same cli option""" + return _request_param_or_config_option_or_default(request, 'espemu_cli_args', None) + + +@pytest.fixture +@multi_dut_argument +def espemu_extra_args(request: FixtureRequest) -> str | None: + """Enable parametrization for the same cli option""" + return _request_param_or_config_option_or_default(request, 'espemu_extra_args', None) + + @pytest.fixture @multi_dut_argument def skip_regenerate_image(request: FixtureRequest) -> str | None: @@ -1166,6 +1218,10 @@ def parametrize_fixtures( qemu_cli_args, qemu_extra_args, qemu_efuse_path, + espemu_image_path, + espemu_prog_path, + espemu_cli_args, + espemu_extra_args, wokwi_diagram, wokwi_usb_serial_jtag, skip_regenerate_image, @@ -1257,6 +1313,13 @@ def qemu(_fixture_classes_and_options: ClassCliOptions, app) -> t.Optional['Qemu return qemu_gn(**locals()) +@pytest.fixture +@multi_dut_generator_fixture +def espemu(_fixture_classes_and_options: ClassCliOptions, app) -> t.Optional['EspEmu']: + """An esp-emu subprocess that could read/redirect/write""" + return espemu_gn(**locals()) + + @pytest.fixture @multi_dut_generator_fixture def wokwi(_fixture_classes_and_options: ClassCliOptions, app) -> t.Optional['Wokwi']: @@ -1274,6 +1337,7 @@ def dut( serial: t.Union['Serial', 'LinuxSerial'] | None, qemu: t.Optional['Qemu'], wokwi: t.Optional['Wokwi'], + espemu: t.Optional['EspEmu'], ) -> Dut | list[Dut]: """ A device under test (DUT) object that could gather output from various sources and redirect them to the pexpect diff --git a/pytest-embedded/pytest_embedded/utils.py b/pytest-embedded/pytest_embedded/utils.py index fb972794..f486b06b 100644 --- a/pytest-embedded/pytest_embedded/utils.py +++ b/pytest-embedded/pytest_embedded/utils.py @@ -23,19 +23,21 @@ 'idf': f'{BASE_LIB_NAME}-idf', 'jtag': f'{BASE_LIB_NAME}-jtag', 'qemu': f'{BASE_LIB_NAME}-qemu', + 'espemu': f'{BASE_LIB_NAME}-espemu', 'arduino': f'{BASE_LIB_NAME}-arduino', 'wokwi': f'{BASE_LIB_NAME}-wokwi', 'nuttx': f'{BASE_LIB_NAME}-nuttx', } FIXTURES_SERVICES = { - 'app': ['base', 'idf', 'qemu', 'arduino', 'nuttx'], + 'app': ['base', 'idf', 'qemu', 'espemu', 'arduino', 'nuttx'], 'serial': ['serial', 'jtag', 'esp', 'idf', 'arduino', 'nuttx'], 'openocd': ['jtag'], 'gdb': ['jtag'], 'qemu': ['qemu'], + 'espemu': ['espemu'], 'wokwi': ['wokwi'], - 'dut': ['base', 'serial', 'jtag', 'qemu', 'idf', 'wokwi', 'nuttx'], + 'dut': ['base', 'serial', 'jtag', 'qemu', 'espemu', 'idf', 'wokwi', 'nuttx'], } From 61c8d5e8aec2f068d9b1306817c8f6aba597fc40 Mon Sep 17 00:00:00 2001 From: Shubham Patil Date: Tue, 11 Aug 2026 14:20:58 +0530 Subject: [PATCH 2/4] ci: add espemu service tests Cover the service wiring in test_base's service-to-classes parametrization, and add pytester-based tests mirroring pytest-embedded-qemu's against the hello_world_esp32c3 fixture app, skipped when esp-emu is not on PATH: basic expect, multi-count (two emulator instances), and a clear error for unsupported (non-RISC-V) targets. Run them in a test-espemu job that installs esp-emu via its upstream install script. --- .github/workflows/test-python.yml | 43 ++++++++++++ pytest-embedded-espemu/tests/test_espemu.py | 72 +++++++++++++++++++++ pytest-embedded/tests/test_base.py | 3 +- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 pytest-embedded-espemu/tests/test_espemu.py diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index cb2cccbb..8803d404 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -54,6 +54,7 @@ jobs: --cov=pytest_embedded_idf \ --cov=pytest_embedded_jtag \ --cov=pytest_embedded_qemu \ + --cov=pytest_embedded_espemu \ --cov=pytest_embedded_serial \ --cov=pytest_embedded_serial_esp \ --cov=pytest_embedded_wokwi \ @@ -68,6 +69,48 @@ jobs: pytest-qemu.xml coverage-qemu.xml + test-espemu: + timeout-minutes: 40 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - name: Install dependencies + run: | + pip install -U pip + export PIP_EXTRA_INDEX_URL="https://dl.espressif.com/pypi" + pip install cryptography --prefer-binary + pip install -r requirements.txt + bash foreach.sh install + - name: Download and install esp-emu + run: | + curl -fsSL https://raw.githubusercontent.com/espressif/esp-emulator/main/install.sh | sh -s -- --version 0.38.0 + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Save PR number + run: echo ${{ github.event.number }} > pr_number.txt + + - name: Run esp-emu tests + run: | + pytest pytest-embedded-espemu/tests/test_espemu.py \ + --junitxml pytest-espemu.xml \ + --cov-report=xml:coverage-espemu.xml \ + --cov=pytest_embedded \ + --cov=pytest_embedded_espemu \ + --cov=pytest_embedded_idf \ + --cov=pytest_embedded_serial + - name: Upload test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-espemu + path: | + pr_number.txt + pytest-espemu.xml + coverage-espemu.xml + test-python: timeout-minutes: 40 strategy: diff --git a/pytest-embedded-espemu/tests/test_espemu.py b/pytest-embedded-espemu/tests/test_espemu.py new file mode 100644 index 00000000..260ef321 --- /dev/null +++ b/pytest-embedded-espemu/tests/test_espemu.py @@ -0,0 +1,72 @@ +import os +import shutil + +import pytest + +espemu_bin_required = pytest.mark.skipif( + shutil.which('esp-emu') is None, + reason='Please make sure esp-emu is installed and on PATH. See https://github.com/espressif/esp-emulator#install', +) + + +@espemu_bin_required +def test_pexpect_by_espemu(testdir): + testdir.makepyfile(""" + import pexpect + import pytest + + def test_pexpect_by_espemu(dut): + dut.expect('Hello world!') + dut.expect('Restarting') + with pytest.raises(pexpect.TIMEOUT): + dut.expect('foo bar not found', timeout=1) + """) + + result = testdir.runpytest( + '-s', + '--embedded-services', + 'idf,espemu', + '--app-path', + os.path.join(testdir.tmpdir, 'hello_world_esp32c3'), + ) + + result.assert_outcomes(passed=1) + + +@espemu_bin_required +def test_multi_count_espemu(testdir): + testdir.makepyfile(""" + def test_multi_count_espemu(dut): + dut[0].expect('Hello world!') + dut[1].expect('Restarting') + """) + + result = testdir.runpytest( + '-s', + '--count', + 2, + '--embedded-services', + 'idf,espemu|idf,espemu', + '--app-path', + f'{os.path.join(testdir.tmpdir, "hello_world_esp32c3")}|{os.path.join(testdir.tmpdir, "hello_world_esp32c3")}', + ) + + result.assert_outcomes(passed=1) + + +@espemu_bin_required +def test_unsupported_target_espemu(testdir): + testdir.makepyfile(""" + def test_unsupported_target_espemu(dut): + pass + """) + + result = testdir.runpytest( + '-s', + '--embedded-services', + 'idf,espemu', + '--app-path', + os.path.join(testdir.tmpdir, 'hello_world_esp32'), # xtensa, not emulated + ) + + result.assert_outcomes(errors=1) diff --git a/pytest-embedded/tests/test_base.py b/pytest-embedded/tests/test_base.py index 89b1cab2..74a4dd87 100644 --- a/pytest-embedded/tests/test_base.py +++ b/pytest-embedded/tests/test_base.py @@ -35,6 +35,7 @@ def _classes(request): ('idf,serial', {'IdfApp', 'Serial', 'SerialDut'}), ('idf,esp', {'IdfApp', 'IdfSerial', 'IdfDut'}), ('idf,qemu', {'QemuApp', 'Qemu', 'QemuDut'}), + ('idf,espemu', {'EspEmuApp', 'EspEmu', 'EspEmuDut'}), ('arduino,esp', {'ArduinoApp', 'ArduinoSerial', 'SerialDut'}), ], indirect=True @@ -45,7 +46,7 @@ def test_services(_fixture_classes_and_options, _classes): result = testdir.runpytest() - result.assert_outcomes(passed=7) + result.assert_outcomes(passed=8) def test_fixtures(testdir): From c0ef895031c27ccdad00cd9411b26363b64f0911 Mon Sep 17 00:00:00 2001 From: Shubham Patil Date: Tue, 11 Aug 2026 14:20:59 +0530 Subject: [PATCH 3/4] docs: add espemu service to services page and API reference --- docs/apis/pytest-embedded-espemu.rst | 18 ++++++++++++++++++ docs/concepts/services.md | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 docs/apis/pytest-embedded-espemu.rst diff --git a/docs/apis/pytest-embedded-espemu.rst b/docs/apis/pytest-embedded-espemu.rst new file mode 100644 index 00000000..5876f289 --- /dev/null +++ b/docs/apis/pytest-embedded-espemu.rst @@ -0,0 +1,18 @@ +######################## + pytest-embedded-espemu +######################## + +.. automodule:: pytest_embedded_espemu.app + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytest_embedded_espemu.dut + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytest_embedded_espemu.espemu + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/concepts/services.md b/docs/concepts/services.md index afb9a24f..7f38bfc1 100644 --- a/docs/concepts/services.md +++ b/docs/concepts/services.md @@ -26,6 +26,7 @@ graph LR pytest-embedded-idf -->|optional, support test on espressif chips| pytest-embedded-serial-esp pytest-embedded-idf -->|optional, support test on qemu| pytest-embedded-qemu + pytest-embedded-idf -->|optional, support test on esp-emu| pytest-embedded-espemu pytest-embedded-idf -->|optional, support test on wokwi| pytest-embedded-wokwi pytest-embedded-arduino -->|optional, support test on espressif chips| pytest-embedded-serial-esp @@ -52,6 +53,9 @@ Activate a service would enable a set of fixtures or add some extra functionalit ```{include} ../../pytest-embedded-qemu/README.md ``` +```{include} ../../pytest-embedded-espemu/README.md +``` + ```{include} ../../pytest-embedded-arduino/README.md ``` From fbf3b45c0450afc524196930cfc055639299dd1f Mon Sep 17 00:00:00 2001 From: Shubham Patil Date: Thu, 13 Aug 2026 18:05:01 +0530 Subject: [PATCH 4/4] always install latest version of esp-emu in the ci --- .github/workflows/test-python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 8803d404..dd999beb 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -86,7 +86,7 @@ jobs: bash foreach.sh install - name: Download and install esp-emu run: | - curl -fsSL https://raw.githubusercontent.com/espressif/esp-emulator/main/install.sh | sh -s -- --version 0.38.0 + curl -fsSL https://raw.githubusercontent.com/espressif/esp-emulator/main/install.sh | sh echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Save PR number