Skip to content

Commit

Permalink
Merge pull request #230 from urish/wokwi
Browse files Browse the repository at this point in the history
feature: Wokwi simulation support (RDT-551)
  • Loading branch information
hfudev authored Oct 25, 2023
2 parents 57cfd98 + e90059f commit 5fcdeaf
Show file tree
Hide file tree
Showing 11 changed files with 455 additions and 2 deletions.
1 change: 1 addition & 0 deletions foreach.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ DEFAULT_PACKAGES=" \
pytest-embedded-jtag \
pytest-embedded-qemu \
pytest-embedded-arduino \
pytest-embedded-wokwi \
"

action=${1:-"install"}
Expand Down
21 changes: 21 additions & 0 deletions pytest-embedded-wokwi/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2023 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.
37 changes: 37 additions & 0 deletions pytest-embedded-wokwi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
### pytest-embedded-wokwi

pytest-embedded service for running tests on [Wokwi](https://wokwi.com/ci) instead of the real target.

Wokwi supports most ESP32 targets, including: esp32, esp32s2, esp32s3, esp32c3, esp32c6, and esp32h2. In addition, it supports a [wide range of peripherals](https://docs.wokwi.com/getting-started/supported-hardware), including sensors, displays, motors, and debugging tools.

Running the tests with Wokwi requires an internet connection. Your firmware is uploaded to the Wokwi server for the duration of the simulation, but it is not saved on the server. On-premises Wokwi installations are available for enterprise customers.

## Wokwi CLI installation

The Wokwi plugin uses the [Wokwi CLI](https://github.com/wokwi/wokwi-cli) to interact with the wokwi simulation server. You can download the precompiled CLI binaries from the [releases page](https://github.com/wokwi/wokwi-cli/releases). Alternatively, on Linux or Mac OS, you can install the CLI using the following command:

```bash
curl -L https://wokwi.com/ci/install.sh | sh
```

And on Windows:

```powershell
iwr https://wokwi.com/ci/install.ps1 -useb | iex
```

## API Tokens

Before using this plugin, you need to create a free Wokwi account and [generate an API key](https://wokwi.com/dashboard/ci). You can then set the `WOKWI_CLI_TOKEN` environment variable to the API key.

Linux / Mac OS / WSL:

```bash
export WOKWI_CLI_TOKEN="your-api-key"
```

Windows PowerShell:

```powershell
$env:WOKWI_CLI_TOKEN="your-api-key"
```
107 changes: 107 additions & 0 deletions pytest-embedded-wokwi/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"

[project]
name = "pytest-embedded-wokwi"
authors = [
{name = "Fu Hanxi", email = "fuhanxi@espressif.com"},
{name = "Uri Shaked", email = "uri@wokwi.com"},
]
readme = "README.md"
license = {file = "LICENSE"}
classifiers = [
"Development Status :: 5 - Production/Stable",
"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.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python",
"Topic :: Software Development :: Testing",
]
dynamic = ["version", "description"]
requires-python = ">=3.7"

dependencies = [
"pytest-embedded~=1.3.5",
"toml~=0.10.2",
]

[project.optional-dependencies]
idf = [
"pytest-embedded-idf~=1.3.5",
]

[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"

[tool.isort]
profile = 'black'

[tool.black]
line-length = 120
target-version = ['py37']
force-exclude = '/tests/'
skip-string-normalization = true

[tool.ruff]
select = [
'F', # Pyflakes
'E', # pycodestyle
'W', # pycodestyle
# 'C90', # mccabe
# 'I', # isort
# 'N', # pep8-naming
# 'D', # pydocstyle
# 'UP', # pyupgrade
# 'YTT', # flake8-2020
# 'ANN', # flake8-annotations
# 'S', # flake8-bandit
# 'BLE', # flake8-blind-except
# 'FBT', # flake8-boolean-trap
# 'B', # flake8-bugbear
# 'A', # flake8-builtins
# 'COM', # flake8-commas
# 'C4', # flake8-comprehensions
# 'DTZ', # flake8-datetimez
# 'T10', # flake8-debugger
# 'DJ', # flake8-django
# 'EM', # flake8-errmsg
# 'EXE', # flake8-executable
# 'ISC', # flake8-implicit-str-concat
# 'ICN', # flake8-import-conventions
# 'G', # flake8-logging-format
# 'INP', # flake8-no-pep420
# 'PIE', # flake8-pie
# 'T20', # flake8-print
# 'PYI', # flake8-pyi
# 'PT', # flake8-pytest-style
# 'Q', # flake8-quotes
# 'RSE', # flake8-raise
# 'RET', # flake8-return
# 'SLF', # flake8-self
# 'SIM', # flake8-simplify
# 'TID', # flake8-tidy-imports
# 'TCH', # flake8-type-checking
# 'ARG', # flake8-unused-arguments
# 'PTH', # flake8-use-pathlib
# 'ERA', # eradicate
# 'PD', # pandas-vet
# 'PGH', # pygrep-hooks
# 'PL', # Pylint
# 'TRY', # tryceratops
# 'NPY', # NumPy-specific rules
# 'RUF', # Ruff-specific rules
]
line-length = 120
target-version = "py37"
11 changes: 11 additions & 0 deletions pytest-embedded-wokwi/pytest_embedded_wokwi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Make pytest-embedded plugin work with the Wokwi CLI."""

from .dut import WokwiDut # noqa
from .wokwi_cli import WokwiCLI # noqa

__all__ = [
'WokwiCLI',
'WokwiDut',
]

__version__ = '1.3.5'
25 changes: 25 additions & 0 deletions pytest-embedded-wokwi/pytest_embedded_wokwi/dut.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import AnyStr

from pytest_embedded.dut import Dut

from .wokwi_cli import WokwiCLI


class WokwiDut(Dut):
"""
Wokwi DUT class
"""

def __init__(
self,
wokwi: WokwiCLI,
**kwargs,
) -> None:
self.wokwi = wokwi

super().__init__(**kwargs)

self._hard_reset_func = self.wokwi._hard_reset

def write(self, s: AnyStr) -> None:
self.wokwi.write(s)
14 changes: 14 additions & 0 deletions pytest-embedded-wokwi/pytest_embedded_wokwi/idf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import typing as t
from pathlib import Path

if t.TYPE_CHECKING:
from pytest_embedded_idf.app import IdfApp


class IDFFirmwareResolver:
"""
IDFFirmwareResolver class
"""

def resolve_firmware(self, app: 'IdfApp'):
return Path(app.binary_path, 'flasher_args.json')
129 changes: 129 additions & 0 deletions pytest-embedded-wokwi/pytest_embedded_wokwi/wokwi_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import json
import logging
import os
import typing as t
from pathlib import Path

import toml
from pytest_embedded import __version__
from pytest_embedded.log import DuplicateStdoutPopen

from .idf import IDFFirmwareResolver

if t.TYPE_CHECKING:
from pytest_embedded_idf.app import IdfApp


target_to_board = {
'esp32': 'board-esp32-devkit-c-v4',
'esp32c3': 'board-esp32-c3-devkitm-1',
'esp32c6': 'board-esp32-c6-devkitc-1',
'esp32h2': 'board-esp32-h2-devkitm-1',
'esp32s2': 'board-esp32-s2-devkitm-1',
'esp32s3': 'board-esp32-s3-devkitc-1',
}


class WokwiCLI(DuplicateStdoutPopen):
"""
WokwiCLI class
"""

SOURCE = 'Wokwi'

WOKWI_CLI_PATH = 'wokwi-cli'

def __init__(
self,
firmware_resolver: IDFFirmwareResolver,
wokwi_cli_path: t.Optional[str] = None,
app: t.Optional['IdfApp'] = None,
**kwargs,
):
"""
Args:
wokwi_cli_path: Wokwi CLI arguments
"""
self.app = app
self.firmware_resolver = firmware_resolver

self.create_wokwi_toml()
self.create_diagram_json()

wokwi_cli = wokwi_cli_path or self.wokwi_cli_executable

super().__init__(
cmd=[wokwi_cli, app.app_path],
**kwargs,
)

@property
def wokwi_cli_executable(self):
return self.WOKWI_CLI_PATH

def create_wokwi_toml(self):
app = self.app
flasher_args = self.firmware_resolver.resolve_firmware(app)
wokwi_toml_path = os.path.join(app.app_path, 'wokwi.toml')
firmware_path = Path(flasher_args).relative_to(app.app_path).as_posix()
elf_path = Path(app.elf_file).relative_to(app.app_path).as_posix()

if os.path.exists(wokwi_toml_path):
with open(wokwi_toml_path, 'rt') as f:
toml_data = toml.load(f)

if 'wokwi' not in toml_data:
toml_data['wokwi'] = {'version': 1}

wokwi_table = toml_data['wokwi']
if wokwi_table.get('firmware') == firmware_path and wokwi_table.get('elf') == elf_path:
# No need to update
return

wokwi_table.update({'firmware': firmware_path, 'elf': elf_path})
else:
toml_data = {
'wokwi': {
'version': 1,
'generatedBy': f'pytest-embedded-wokwi {__version__}',
'firmware': firmware_path,
'elf': elf_path,
}
}

with open(wokwi_toml_path, 'wt') as f:
toml.dump(toml_data, f)

def create_diagram_json(self):
app = self.app
diagram_json_path = os.path.join(app.app_path, 'diagram.json')
target_board = target_to_board[app.target]

if os.path.exists(diagram_json_path):
with open(diagram_json_path, 'rt') as f:
json_data = json.load(f)
if not any(part['type'] == target_board for part in json_data['parts']):
logging.warning(
f'diagram.json exists, no part with type "{target_board}" found. '
+ 'You may need to update the diagram.json file manually to match the target board.'
)
return

diagram = {
'version': 1,
'author': 'Uri Shaked',
'editor': 'wokwi',
'parts': [{'type': target_board, 'id': 'esp'}],
'connections': [
['esp:TX', '$serialMonitor:RX', ''],
['esp:RX', '$serialMonitor:TX', ''],
],
}
with open(diagram_json_path, 'wt') as f:
f.write(json.dumps(diagram, indent=2))

def _hard_reset(self):
"""
This is a fake hard_reset. Keep this API to keep the consistency.
"""
raise NotImplementedError
38 changes: 38 additions & 0 deletions pytest-embedded-wokwi/tests/test_wokwi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import os
import shutil

import pytest

wokwi_cli_required = pytest.mark.skipif(
shutil.which('wokwi-cli') is None,
reason='Please make sure that `wokwi-cli` is in your PATH env var. '
+ 'To install: https://docs.wokwi.com/wokwi-ci/getting-started#cli-installation'
)

wokwi_token_required = pytest.mark.skipif(
os.getenv('WOKWI_CLI_TOKEN') is None,
reason='Please make sure that `WOKWI_CLI_TOKEN` env var is set. Get a token here: https://wokwi.com/dashboard/ci'
)


@wokwi_cli_required
@wokwi_token_required
def test_pexpect_by_wokwi_esp32(testdir):
testdir.makepyfile("""
import pexpect
import pytest
def test_pexpect_by_wokwi(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,wokwi',
'--app-path', os.path.join(testdir.tmpdir, 'hello_world_esp32'),
)

result.assert_outcomes(passed=1)
Loading

0 comments on commit 5fcdeaf

Please sign in to comment.