lte-launch-test 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lte_launch_test-0.1.0/LICENSE +13 -0
- lte_launch_test-0.1.0/MANIFEST.in +2 -0
- lte_launch_test-0.1.0/PKG-INFO +113 -0
- lte_launch_test-0.1.0/README.md +89 -0
- lte_launch_test-0.1.0/package.xml +37 -0
- lte_launch_test-0.1.0/pyproject.toml +5 -0
- lte_launch_test-0.1.0/resource/lte_launch_test +0 -0
- lte_launch_test-0.1.0/setup.cfg +42 -0
- lte_launch_test-0.1.0/setup.py +4 -0
- lte_launch_test-0.1.0/src/lte_launch_test/__init__.py +44 -0
- lte_launch_test-0.1.0/src/lte_launch_test/bag.py +52 -0
- lte_launch_test-0.1.0/src/lte_launch_test/checks.py +72 -0
- lte_launch_test-0.1.0/src/lte_launch_test/plugin.py +83 -0
- lte_launch_test-0.1.0/src/lte_launch_test/probe.py +273 -0
- lte_launch_test-0.1.0/src/lte_launch_test.egg-info/PKG-INFO +113 -0
- lte_launch_test-0.1.0/src/lte_launch_test.egg-info/SOURCES.txt +18 -0
- lte_launch_test-0.1.0/src/lte_launch_test.egg-info/dependency_links.txt +1 -0
- lte_launch_test-0.1.0/src/lte_launch_test.egg-info/requires.txt +3 -0
- lte_launch_test-0.1.0/src/lte_launch_test.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 Spencer Diehl
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lte-launch-test
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy
|
|
5
|
+
Home-page: https://github.com/bigdiehl/lte_launch_test
|
|
6
|
+
Author: Spencer Diehl
|
|
7
|
+
Author-email: docdiehl@gmail.com
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Keywords: ros2,ros,launch,testing,pytest,launch_testing
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: libtmux-easy>=0.2.0
|
|
21
|
+
Requires-Dist: pytest<9,>=7.0
|
|
22
|
+
Requires-Dist: pyyaml
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# lte_launch_test
|
|
26
|
+
|
|
27
|
+
ROS 2 launch tests on pytest: a replacement for `ros2 launch_test` /
|
|
28
|
+
`launch_testing`. [libtmux_easy](https://github.com/nasa-jpl/libtmux_easy)'s pytest plugin runs the
|
|
29
|
+
processes (subprocesses by default, tmux with `--tmux`); this package adds
|
|
30
|
+
the ROS parts. libtmux_easy stays ROS-agnostic.
|
|
31
|
+
|
|
32
|
+
Processes are shell commands; a launch file is one too (`ros2 launch
|
|
33
|
+
file.py`). Design and `launch_testing` parity: [docs/](docs/README.md).
|
|
34
|
+
|
|
35
|
+
ROS 2 Humble and Jazzy.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
| how | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| pip | `pip install lte-launch-test` in a venv made with `--system-site-packages` (it needs ROS's `rclpy`) |
|
|
42
|
+
| `.deb` (Ubuntu 24.04) | from the GitHub Releases, with libtmux_easy's |
|
|
43
|
+
| colcon | in your workspace's `src/`, with `package.xml` |
|
|
44
|
+
|
|
45
|
+
Details: [docs/packaging.md](docs/packaging.md).
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
cd examples
|
|
51
|
+
./run.sh test_01_topic.py -v # start here; numbered simplest first
|
|
52
|
+
LTE_DEMO_CRASH=1 ./run.sh test_09_tour.py -v # a node dies mid-scenario
|
|
53
|
+
./run.sh # all of them
|
|
54
|
+
./run.sh --tmux # the dashboard; --no-attach: detached tmux
|
|
55
|
+
./run.sh --keep-open # leave every node running after the run
|
|
56
|
+
./run.sh failing # each fails on purpose
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`run.sh` uses ROS as sourced (else `/opt/ros/$ROS_DISTRO`, default Jazzy), puts `src/` on the path, and uses
|
|
60
|
+
`../libtmux_easy/src` if libtmux_easy isn't installed.
|
|
61
|
+
|
|
62
|
+
## Use
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# conftest.py
|
|
66
|
+
pytest_plugins = ["lte_launch_test.plugin"]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| name | what |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `lte_launch_test.plugin` | a per-run `ROS_DOMAIN_ID` (reserved via `domain_coordinator`) + localhost discovery; every process inherits pytest's environment (ROS and overlays), runs in the rootdir, logs ROS to `$LTE_LOG_DIR/ros`; a `RosProbe` made in a test is destroyed after it |
|
|
72
|
+
| `RosProbe(log_dir=)` | rclpy node (does `rclpy.init()` first; its ROS logs in `<log_dir>/ros_probe`); `with RosProbe() as probe:` in a test, or `RosProbe()` in `setup_class` and `destroy()` in teardown; subscribes with QoS that matches any publisher; waits (`wait_for_nodes`, `wait_for_topics`, `wait_for_services`, `wait_for_messages`, `rate`, `call`, `publish_until`) call `check_health()`, so a death fails them at once |
|
|
73
|
+
| `RosProbe` services, params | `call(Srv, name, a=2, b=40)` (or a request); another node's parameters: `get_param(node, name)`, `get_params`, `set_param(node, name, value)`, `set_params(node, {...})` (fails with the node's reason if rejected), `list_params(node)` |
|
|
74
|
+
| `RosProbe` tf | `wait_for_transform(target, source)`: a `TransformStamped` once tf has it (chains through the tree); the failure lists the transforms it has |
|
|
75
|
+
| `launch_death(allow=)` | `fail_on` check: a node died inside `ros2 launch` (non-zero exit; SIGINT/SIGTERM and exit 0 are fine) |
|
|
76
|
+
| `composable_failure(allow=)` | `fail_on` check: a composable node failed to load (launch's `Failed to load node`, or the container's `Failed to find class` / `Could not find requested resource` / `Failed to load library`) |
|
|
77
|
+
| `wait_for_bag_topics(procs, name, topics, timeout)` | wait for `ros2 bag record` process `name` to subscribe to all its topics; fails naming the missing ones |
|
|
78
|
+
| `check_bag(bag_dir, min_messages)` | after the recorder stopped: fail on each topic below its `{topic: n}` count (from `metadata.yaml`); `bag_counts()`: `{topic: n}` |
|
|
79
|
+
| `Processes`, `check_health`, `arg`, `parametrize` | re-exported from `libtmux_easy.testing` |
|
|
80
|
+
|
|
81
|
+
## colcon
|
|
82
|
+
|
|
83
|
+
Verified with `colcon test` on Jazzy, for both build types. Put
|
|
84
|
+
`test/conftest.py` (`pytest_plugins = [...]`) next to the tests; libtmux_easy and
|
|
85
|
+
lte_launch_test must be importable (installed, or on `PYTHONPATH`).
|
|
86
|
+
|
|
87
|
+
| build type | add |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `ament_python` | nothing: colcon runs pytest on `test/` |
|
|
90
|
+
| `ament_cmake` | `find_package(ament_cmake_pytest REQUIRED)` and `ament_add_pytest_test(test_talker test/test_talker.py TIMEOUT 120)` |
|
|
91
|
+
|
|
92
|
+
## Layout
|
|
93
|
+
|
|
94
|
+
| path | what |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `src/lte_launch_test/` | the package |
|
|
97
|
+
| `docs/` | design, `launch_testing` parity, packaging, [future development](docs/FUTURE_DEVELOPMENT.md) |
|
|
98
|
+
| `examples/` | numbered example tests, simplest first; `failing/` fails on purpose. See [examples/](examples/README.md) |
|
|
99
|
+
| `test/` | `test/run.sh`: every failure check, unit and live (launch deaths, container crash, composable load errors, bag topics/counts, probe waits, services, params, tf), and that `examples/failing` fails as documented |
|
|
100
|
+
| `debian/`, `package.xml`, `setup.cfg` | the `.deb`, colcon, pip |
|
|
101
|
+
| `.github/workflows/` | CI (Humble, Jazzy), release |
|
|
102
|
+
|
|
103
|
+
## Notes
|
|
104
|
+
|
|
105
|
+
- A failed test stops its class's processes at once (SIGINT, escalating;
|
|
106
|
+
`on_abort=` first, if given) and skips the rest of the class.
|
|
107
|
+
`--keep-open` keeps them instead.
|
|
108
|
+
- Logs: each process's output and ROS logs go to pytest's tmp dir, printed
|
|
109
|
+
at the end as `process logs: <dir>`.
|
|
110
|
+
- A node that should exit 0 on SIGINT must catch `KeyboardInterrupt` /
|
|
111
|
+
`ExternalShutdownException` around `rclpy.spin()` (see `examples/nodes/counter_node.py`).
|
|
112
|
+
- A stopped node's name can linger in the graph briefly. Give per-test
|
|
113
|
+
nodes names of their own (`-r __node:=...`).
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# lte_launch_test
|
|
2
|
+
|
|
3
|
+
ROS 2 launch tests on pytest: a replacement for `ros2 launch_test` /
|
|
4
|
+
`launch_testing`. [libtmux_easy](https://github.com/nasa-jpl/libtmux_easy)'s pytest plugin runs the
|
|
5
|
+
processes (subprocesses by default, tmux with `--tmux`); this package adds
|
|
6
|
+
the ROS parts. libtmux_easy stays ROS-agnostic.
|
|
7
|
+
|
|
8
|
+
Processes are shell commands; a launch file is one too (`ros2 launch
|
|
9
|
+
file.py`). Design and `launch_testing` parity: [docs/](docs/README.md).
|
|
10
|
+
|
|
11
|
+
ROS 2 Humble and Jazzy.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
| how | |
|
|
16
|
+
|---|---|
|
|
17
|
+
| pip | `pip install lte-launch-test` in a venv made with `--system-site-packages` (it needs ROS's `rclpy`) |
|
|
18
|
+
| `.deb` (Ubuntu 24.04) | from the GitHub Releases, with libtmux_easy's |
|
|
19
|
+
| colcon | in your workspace's `src/`, with `package.xml` |
|
|
20
|
+
|
|
21
|
+
Details: [docs/packaging.md](docs/packaging.md).
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cd examples
|
|
27
|
+
./run.sh test_01_topic.py -v # start here; numbered simplest first
|
|
28
|
+
LTE_DEMO_CRASH=1 ./run.sh test_09_tour.py -v # a node dies mid-scenario
|
|
29
|
+
./run.sh # all of them
|
|
30
|
+
./run.sh --tmux # the dashboard; --no-attach: detached tmux
|
|
31
|
+
./run.sh --keep-open # leave every node running after the run
|
|
32
|
+
./run.sh failing # each fails on purpose
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`run.sh` uses ROS as sourced (else `/opt/ros/$ROS_DISTRO`, default Jazzy), puts `src/` on the path, and uses
|
|
36
|
+
`../libtmux_easy/src` if libtmux_easy isn't installed.
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# conftest.py
|
|
42
|
+
pytest_plugins = ["lte_launch_test.plugin"]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| name | what |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `lte_launch_test.plugin` | a per-run `ROS_DOMAIN_ID` (reserved via `domain_coordinator`) + localhost discovery; every process inherits pytest's environment (ROS and overlays), runs in the rootdir, logs ROS to `$LTE_LOG_DIR/ros`; a `RosProbe` made in a test is destroyed after it |
|
|
48
|
+
| `RosProbe(log_dir=)` | rclpy node (does `rclpy.init()` first; its ROS logs in `<log_dir>/ros_probe`); `with RosProbe() as probe:` in a test, or `RosProbe()` in `setup_class` and `destroy()` in teardown; subscribes with QoS that matches any publisher; waits (`wait_for_nodes`, `wait_for_topics`, `wait_for_services`, `wait_for_messages`, `rate`, `call`, `publish_until`) call `check_health()`, so a death fails them at once |
|
|
49
|
+
| `RosProbe` services, params | `call(Srv, name, a=2, b=40)` (or a request); another node's parameters: `get_param(node, name)`, `get_params`, `set_param(node, name, value)`, `set_params(node, {...})` (fails with the node's reason if rejected), `list_params(node)` |
|
|
50
|
+
| `RosProbe` tf | `wait_for_transform(target, source)`: a `TransformStamped` once tf has it (chains through the tree); the failure lists the transforms it has |
|
|
51
|
+
| `launch_death(allow=)` | `fail_on` check: a node died inside `ros2 launch` (non-zero exit; SIGINT/SIGTERM and exit 0 are fine) |
|
|
52
|
+
| `composable_failure(allow=)` | `fail_on` check: a composable node failed to load (launch's `Failed to load node`, or the container's `Failed to find class` / `Could not find requested resource` / `Failed to load library`) |
|
|
53
|
+
| `wait_for_bag_topics(procs, name, topics, timeout)` | wait for `ros2 bag record` process `name` to subscribe to all its topics; fails naming the missing ones |
|
|
54
|
+
| `check_bag(bag_dir, min_messages)` | after the recorder stopped: fail on each topic below its `{topic: n}` count (from `metadata.yaml`); `bag_counts()`: `{topic: n}` |
|
|
55
|
+
| `Processes`, `check_health`, `arg`, `parametrize` | re-exported from `libtmux_easy.testing` |
|
|
56
|
+
|
|
57
|
+
## colcon
|
|
58
|
+
|
|
59
|
+
Verified with `colcon test` on Jazzy, for both build types. Put
|
|
60
|
+
`test/conftest.py` (`pytest_plugins = [...]`) next to the tests; libtmux_easy and
|
|
61
|
+
lte_launch_test must be importable (installed, or on `PYTHONPATH`).
|
|
62
|
+
|
|
63
|
+
| build type | add |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `ament_python` | nothing: colcon runs pytest on `test/` |
|
|
66
|
+
| `ament_cmake` | `find_package(ament_cmake_pytest REQUIRED)` and `ament_add_pytest_test(test_talker test/test_talker.py TIMEOUT 120)` |
|
|
67
|
+
|
|
68
|
+
## Layout
|
|
69
|
+
|
|
70
|
+
| path | what |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `src/lte_launch_test/` | the package |
|
|
73
|
+
| `docs/` | design, `launch_testing` parity, packaging, [future development](docs/FUTURE_DEVELOPMENT.md) |
|
|
74
|
+
| `examples/` | numbered example tests, simplest first; `failing/` fails on purpose. See [examples/](examples/README.md) |
|
|
75
|
+
| `test/` | `test/run.sh`: every failure check, unit and live (launch deaths, container crash, composable load errors, bag topics/counts, probe waits, services, params, tf), and that `examples/failing` fails as documented |
|
|
76
|
+
| `debian/`, `package.xml`, `setup.cfg` | the `.deb`, colcon, pip |
|
|
77
|
+
| `.github/workflows/` | CI (Humble, Jazzy), release |
|
|
78
|
+
|
|
79
|
+
## Notes
|
|
80
|
+
|
|
81
|
+
- A failed test stops its class's processes at once (SIGINT, escalating;
|
|
82
|
+
`on_abort=` first, if given) and skips the rest of the class.
|
|
83
|
+
`--keep-open` keeps them instead.
|
|
84
|
+
- Logs: each process's output and ROS logs go to pytest's tmp dir, printed
|
|
85
|
+
at the end as `process logs: <dir>`.
|
|
86
|
+
- A node that should exit 0 on SIGINT must catch `KeyboardInterrupt` /
|
|
87
|
+
`ExternalShutdownException` around `rclpy.spin()` (see `examples/nodes/counter_node.py`).
|
|
88
|
+
- A stopped node's name can linger in the graph briefly. Give per-test
|
|
89
|
+
nodes names of their own (`-r __node:=...`).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<?xml version="1.0"?>
|
|
2
|
+
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
|
3
|
+
<package format="3">
|
|
4
|
+
<name>lte_launch_test</name>
|
|
5
|
+
<version>0.1.0</version>
|
|
6
|
+
<description>ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy</description>
|
|
7
|
+
<maintainer email="docdiehl@gmail.com">Spencer Diehl</maintainer>
|
|
8
|
+
<license>Apache-2.0</license>
|
|
9
|
+
<url type="repository">https://github.com/bigdiehl/lte_launch_test</url>
|
|
10
|
+
|
|
11
|
+
<!-- libtmux_easy has no rosdep key: pip install libtmux-easy, or its .deb. -->
|
|
12
|
+
<exec_depend>domain_coordinator</exec_depend>
|
|
13
|
+
<exec_depend>python3-pytest</exec_depend>
|
|
14
|
+
<exec_depend>python3-yaml</exec_depend>
|
|
15
|
+
<exec_depend>rcl_interfaces</exec_depend>
|
|
16
|
+
<exec_depend>rclpy</exec_depend>
|
|
17
|
+
<exec_depend>tf2_ros_py</exec_depend>
|
|
18
|
+
<exec_depend>tmux</exec_depend>
|
|
19
|
+
|
|
20
|
+
<!-- test/ and examples/ -->
|
|
21
|
+
<test_depend>composition</test_depend>
|
|
22
|
+
<test_depend>demo_nodes_cpp</test_depend>
|
|
23
|
+
<test_depend>demo_nodes_py</test_depend>
|
|
24
|
+
<test_depend>example_interfaces</test_depend>
|
|
25
|
+
<test_depend>launch_ros</test_depend>
|
|
26
|
+
<test_depend>rclcpp_components</test_depend>
|
|
27
|
+
<test_depend>ros2bag</test_depend>
|
|
28
|
+
<test_depend>rosbag2</test_depend>
|
|
29
|
+
<test_depend>std_msgs</test_depend>
|
|
30
|
+
<test_depend>std_srvs</test_depend>
|
|
31
|
+
<test_depend>tf2_msgs</test_depend>
|
|
32
|
+
<test_depend>tf2_ros</test_depend>
|
|
33
|
+
|
|
34
|
+
<export>
|
|
35
|
+
<build_type>ament_python</build_type>
|
|
36
|
+
</export>
|
|
37
|
+
</package>
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[metadata]
|
|
2
|
+
name = lte-launch-test
|
|
3
|
+
version = 0.1.0
|
|
4
|
+
description = ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy
|
|
5
|
+
long_description = file: README.md
|
|
6
|
+
long_description_content_type = text/markdown
|
|
7
|
+
url = https://github.com/bigdiehl/lte_launch_test
|
|
8
|
+
author = Spencer Diehl
|
|
9
|
+
author_email = docdiehl@gmail.com
|
|
10
|
+
license = Apache-2.0
|
|
11
|
+
license_files = LICENSE
|
|
12
|
+
keywords = ros2, ros, launch, testing, pytest, launch_testing
|
|
13
|
+
classifiers =
|
|
14
|
+
Development Status :: 4 - Beta
|
|
15
|
+
Framework :: Pytest
|
|
16
|
+
Intended Audience :: Developers
|
|
17
|
+
Programming Language :: Python :: 3
|
|
18
|
+
Programming Language :: Python :: 3.10
|
|
19
|
+
Programming Language :: Python :: 3.12
|
|
20
|
+
Topic :: Software Development :: Testing
|
|
21
|
+
|
|
22
|
+
[options]
|
|
23
|
+
package_dir =
|
|
24
|
+
= src
|
|
25
|
+
packages = find:
|
|
26
|
+
python_requires = >=3.10
|
|
27
|
+
install_requires =
|
|
28
|
+
libtmux-easy>=0.2.0
|
|
29
|
+
pytest>=7.0,<9
|
|
30
|
+
pyyaml
|
|
31
|
+
|
|
32
|
+
[options.packages.find]
|
|
33
|
+
where = src
|
|
34
|
+
|
|
35
|
+
[options.data_files]
|
|
36
|
+
share/ament_index/resource_index/packages = resource/lte_launch_test
|
|
37
|
+
share/lte_launch_test = package.xml
|
|
38
|
+
|
|
39
|
+
[egg_info]
|
|
40
|
+
tag_build =
|
|
41
|
+
tag_date = 0
|
|
42
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""ROS 2 launch tests on pytest, with libtmux_easy running the processes.
|
|
2
|
+
|
|
3
|
+
libtmux_easy.testing does the generic work (start, watch, stop, logs,
|
|
4
|
+
--tmux, --keep-open); this adds the ROS parts. Enable with:
|
|
5
|
+
|
|
6
|
+
pytest_plugins = ["lte_launch_test.plugin"] # in conftest.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
if "ROS_DISTRO" not in os.environ:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"lte_launch_test: source ROS 2 first, e.g. source /opt/ros/jazzy/setup.bash"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Imported below, before pytest loads it as a plugin: rewrite its asserts anyway.
|
|
19
|
+
pytest.register_assert_rewrite("libtmux_easy.testing")
|
|
20
|
+
|
|
21
|
+
from libtmux_easy.testing import Processes, arg, check_health, parametrize # noqa: E402
|
|
22
|
+
|
|
23
|
+
from .bag import (
|
|
24
|
+
ALL_SUBSCRIBED,
|
|
25
|
+
bag_counts,
|
|
26
|
+
check_bag,
|
|
27
|
+
wait_for_bag_topics,
|
|
28
|
+
) # noqa: E402
|
|
29
|
+
from .checks import composable_failure, launch_death # noqa: E402
|
|
30
|
+
from .probe import RosProbe # noqa: E402
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ALL_SUBSCRIBED",
|
|
34
|
+
"Processes",
|
|
35
|
+
"RosProbe",
|
|
36
|
+
"arg",
|
|
37
|
+
"bag_counts",
|
|
38
|
+
"check_bag",
|
|
39
|
+
"check_health",
|
|
40
|
+
"composable_failure",
|
|
41
|
+
"launch_death",
|
|
42
|
+
"parametrize",
|
|
43
|
+
"wait_for_bag_topics",
|
|
44
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Checks on a 'ros2 bag record' process: it found every topic it was
|
|
2
|
+
asked for, and its bag holds enough messages."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
# rosbag2's recorder, once it has subscribed to every topic it was given.
|
|
10
|
+
ALL_SUBSCRIBED = "All requested topics are subscribed"
|
|
11
|
+
_SUBSCRIBED = re.compile(r"Subscribed to topic '([^']+)'")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def wait_for_bag_topics(procs, name, topics, timeout=30.0):
|
|
15
|
+
"""Wait for recorder 'name' to subscribe to all its 'topics'; fails
|
|
16
|
+
naming the ones it hasn't after 'timeout' s."""
|
|
17
|
+
try:
|
|
18
|
+
procs.wait_for(name, ALL_SUBSCRIBED, timeout)
|
|
19
|
+
except AssertionError as exc:
|
|
20
|
+
missing = sorted(set(topics) - set(_SUBSCRIBED.findall(procs.output(name))))
|
|
21
|
+
# Keep the cause: the recorder may have died, not just not found them.
|
|
22
|
+
raise AssertionError(
|
|
23
|
+
f"{name}: not subscribed to {', '.join(missing) or '?'}" f"\n{exc}"
|
|
24
|
+
) from None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bag_counts(bag_dir):
|
|
28
|
+
"""{topic: messages} from the bag's metadata.yaml, which the recorder
|
|
29
|
+
writes when it stops."""
|
|
30
|
+
with open(os.path.join(bag_dir, "metadata.yaml")) as f:
|
|
31
|
+
info = yaml.safe_load(f)["rosbag2_bagfile_information"]
|
|
32
|
+
return {
|
|
33
|
+
t["topic_metadata"]["name"]: t["message_count"]
|
|
34
|
+
for t in info["topics_with_message_count"]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_bag(bag_dir, min_messages):
|
|
39
|
+
"""Fail naming each topic with fewer messages than min_messages[topic].
|
|
40
|
+
Stop the recorder first."""
|
|
41
|
+
try:
|
|
42
|
+
counts = bag_counts(bag_dir)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise AssertionError(
|
|
45
|
+
f"no {bag_dir}/metadata.yaml: stop the recorder first"
|
|
46
|
+
) from None
|
|
47
|
+
low = [
|
|
48
|
+
f"{topic}: {counts.get(topic, 0)} messages, want at least {n}"
|
|
49
|
+
for topic, n in min_messages.items()
|
|
50
|
+
if counts.get(topic, 0) < n
|
|
51
|
+
]
|
|
52
|
+
assert not low, f"bag {bag_dir}:\n " + "\n ".join(low)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""fail_on checks for ROS output."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# 'ros2 launch' reports a node's death in its output and keeps running.
|
|
6
|
+
_LAUNCH_DIED = re.compile(
|
|
7
|
+
r"\[ERROR\] \[(?P<name>[\w.]+?)(-\d+)?\]: process has died "
|
|
8
|
+
r"\[pid \d+, exit code (?P<code>-?\d+)"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# A composable node 'ros2 launch' could not load into its container.
|
|
12
|
+
_LOAD_FAILED = re.compile(
|
|
13
|
+
r"\[ERROR\] \[launch_ros\.actions\.load_composable_nodes\]: "
|
|
14
|
+
r"Failed to load node '(?P<name>[^']+)' of type '(?P<type>[^']+)' "
|
|
15
|
+
r"in container '(?P<container>[^']+)': (?P<reason>.*)"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# The container's own log, when it cannot create a component: its logger
|
|
19
|
+
# name, and the plugin only for a missing class.
|
|
20
|
+
_CONTAINER_ERROR = re.compile(
|
|
21
|
+
r"\[ERROR\] .*?\[(?P<container>[^\]]+)\]: (?P<what>Failed to find class with the requested "
|
|
22
|
+
r"plugin name(?: '(?P<plugin>[^']*)')?|Could not find requested "
|
|
23
|
+
r"resource in ament index|Failed to load library)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Exit codes launch reports for a stop on purpose: SIGINT or SIGTERM, as a
|
|
27
|
+
# signal (-N) or a shell status (128+N). Same semantics as classify() in
|
|
28
|
+
# core_launch's launch_monitor.py (kept apart: this package doesn't import it).
|
|
29
|
+
_STOP_CODES = {-2, -15, 130, 143}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_failure(code):
|
|
33
|
+
"""Exit 0 and SIGINT/SIGTERM are not; any other code is."""
|
|
34
|
+
return code != 0 and code not in _STOP_CODES
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def launch_death(allow=()):
|
|
38
|
+
"""A fail_on check: a node died inside 'ros2 launch'. Exit 0, a stop by
|
|
39
|
+
SIGINT/SIGTERM, and the names in 'allow', are fine."""
|
|
40
|
+
allow = set(allow)
|
|
41
|
+
|
|
42
|
+
def check(line):
|
|
43
|
+
m = _LAUNCH_DIED.search(line)
|
|
44
|
+
if m and _is_failure(int(m["code"])) and m["name"] not in allow:
|
|
45
|
+
return f"node {m['name']} died (exit code {m['code']})"
|
|
46
|
+
|
|
47
|
+
return check
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def composable_failure(allow=()):
|
|
51
|
+
"""A fail_on check: a composable node failed to load, from 'ros2 launch'
|
|
52
|
+
("Failed to load node ...") or from the container's own error ("Failed to
|
|
53
|
+
find class", "Could not find requested resource", "Failed to load
|
|
54
|
+
library"). 'allow': node names, types or containers ("/cont" from launch,
|
|
55
|
+
"cont" from the container's log) and plugin names to ignore. The container's
|
|
56
|
+
"Could not find requested resource" line names no node: allow the container."""
|
|
57
|
+
allow = set(allow)
|
|
58
|
+
|
|
59
|
+
def check(line):
|
|
60
|
+
m = _LOAD_FAILED.search(line)
|
|
61
|
+
if m:
|
|
62
|
+
if {m["name"], m["type"], m["container"]} & allow:
|
|
63
|
+
return None
|
|
64
|
+
return (
|
|
65
|
+
f"composable node {m['name']} ({m['type']}) failed to load "
|
|
66
|
+
f"in {m['container']}: {m['reason'].strip()}"
|
|
67
|
+
)
|
|
68
|
+
m = _CONTAINER_ERROR.search(line)
|
|
69
|
+
if m and not {m["plugin"], m["container"]} & allow:
|
|
70
|
+
return f"container error: {m['what']}"
|
|
71
|
+
|
|
72
|
+
return check
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The pytest plugin: libtmux_easy.testing, plus
|
|
2
|
+
|
|
3
|
+
- each run its own ROS_DOMAIN_ID (reserved through domain_coordinator, as
|
|
4
|
+
launch_testing_ros does), localhost only
|
|
5
|
+
- lte_pane_setup: every process runs in pytest's rootdir, in this run's
|
|
6
|
+
domain, and writes its ROS logs next to its process log (LTE_LOG_DIR)
|
|
7
|
+
- a RosProbe made in a test is destroyed after it
|
|
8
|
+
- pytest's own ignores (--ignore, collect_ignore, norecursedirs) work
|
|
9
|
+
beside launch_testing 3.4.12, which disables them
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from domain_coordinator import domain_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Before rclpy.init(), and given to every process: keep this run's DDS
|
|
20
|
+
# traffic to itself and to this machine. The domain stays reserved (a bound
|
|
21
|
+
# port) until the run ends, so runs at once get different ones.
|
|
22
|
+
if "ROS_DOMAIN_ID" not in os.environ:
|
|
23
|
+
_domain = domain_id()
|
|
24
|
+
os.environ["ROS_DOMAIN_ID"] = str(_domain.__enter__())
|
|
25
|
+
# Localhost only: Humble's switch; Iron and later replaced it.
|
|
26
|
+
_LOCAL = (
|
|
27
|
+
("ROS_LOCALHOST_ONLY", "1")
|
|
28
|
+
if os.environ.get("ROS_DISTRO") == "humble"
|
|
29
|
+
else ("ROS_AUTOMATIC_DISCOVERY_RANGE", "LOCALHOST")
|
|
30
|
+
)
|
|
31
|
+
os.environ.setdefault(*_LOCAL)
|
|
32
|
+
|
|
33
|
+
from . import probe as _probe
|
|
34
|
+
|
|
35
|
+
pytest_plugins = ["libtmux_easy.testing"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(scope="session")
|
|
39
|
+
def lte_pane_setup(pytestconfig):
|
|
40
|
+
# Processes inherit pytest's environment, ROS and any overlay included:
|
|
41
|
+
# sourcing ROS again would put the underlay first.
|
|
42
|
+
return (
|
|
43
|
+
f"export ROS_DOMAIN_ID={os.environ['ROS_DOMAIN_ID']}"
|
|
44
|
+
f" {_LOCAL[0]}={os.environ[_LOCAL[0]]}"
|
|
45
|
+
' ROS_LOG_DIR="$LTE_LOG_DIR/ros"'
|
|
46
|
+
f" && cd {shlex.quote(str(pytestconfig.rootpath))}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
51
|
+
def pytest_runtest_call(item):
|
|
52
|
+
"""A RosProbe made in a test is destroyed after it, 'with' or not."""
|
|
53
|
+
_probe._test_probes = []
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
made, _probe._test_probes = _probe._test_probes, None
|
|
58
|
+
for probe in made:
|
|
59
|
+
probe.destroy()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Workaround: launch_testing 3.4.12 (Jazzy's apt package) disables pytest's
|
|
63
|
+
# --ignore, collect_ignore and norecursedirs for every project with ROS sourced.
|
|
64
|
+
#
|
|
65
|
+
# pytest asks pytest_ignore_collect(path) of every plugin before collecting a
|
|
66
|
+
# path; the first answer that isn't None wins. pytest's own ignore rules are
|
|
67
|
+
# its default implementation of that hook. launch_testing 3.4.12 registers one
|
|
68
|
+
# with tryfirst=True that returns False ("don't ignore") for everything but
|
|
69
|
+
# launch files, so pytest's default is never asked. (Fixed upstream: it returns
|
|
70
|
+
# None now. Seen in CI: examples/failing/ collected despite norecursedirs.)
|
|
71
|
+
#
|
|
72
|
+
# This hook is tryfirst too, and registered later (conftest pytest_plugins load
|
|
73
|
+
# after setuptools entry points), so pluggy calls it first. It asks pytest's
|
|
74
|
+
# default: True (ignore) wins; else None, and the other hooks decide as before.
|
|
75
|
+
# _pytest.main is private, but this function has had this signature since
|
|
76
|
+
# pytest 7. Remove once Jazzy ships the fix: test/test_plugin_collect.py checks
|
|
77
|
+
# both the bug and this workaround.
|
|
78
|
+
@pytest.hookimpl(tryfirst=True)
|
|
79
|
+
def pytest_ignore_collect(collection_path, config):
|
|
80
|
+
"""pytest's own ignores first: --ignore, collect_ignore, norecursedirs."""
|
|
81
|
+
from _pytest.main import pytest_ignore_collect as pytest_default
|
|
82
|
+
|
|
83
|
+
return True if pytest_default(collection_path=collection_path, config=config) else None
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""RosProbe: an rclpy node the tests talk to the system with. Nothing here
|
|
2
|
+
knows about tmux or subprocesses: libtmux_easy's check_health() is how a
|
|
3
|
+
wait fails fast on a death."""
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import rclpy
|
|
10
|
+
import rclpy.time
|
|
11
|
+
import yaml
|
|
12
|
+
from rcl_interfaces.msg import ParameterType
|
|
13
|
+
from rcl_interfaces.srv import GetParameters, ListParameters, SetParameters
|
|
14
|
+
from rclpy.parameter import Parameter, parameter_value_to_python
|
|
15
|
+
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy
|
|
16
|
+
|
|
17
|
+
from libtmux_easy.testing import check_health
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Matches any publisher: BEST_EFFORT takes RELIABLE and BEST_EFFORT ones,
|
|
21
|
+
# VOLATILE takes VOLATILE and TRANSIENT_LOCAL ones (e.g. a lidar's sensor QoS).
|
|
22
|
+
ANY_PUBLISHER = QoSProfile(
|
|
23
|
+
depth=10,
|
|
24
|
+
reliability=ReliabilityPolicy.BEST_EFFORT,
|
|
25
|
+
durability=DurabilityPolicy.VOLATILE,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ros_init(log_dir=None):
|
|
30
|
+
"""rclpy.init() once per run. 'log_dir': where this process's ROS logs
|
|
31
|
+
go (ROS_LOG_DIR), read at the first init only."""
|
|
32
|
+
if not rclpy.ok():
|
|
33
|
+
if log_dir:
|
|
34
|
+
os.environ["ROS_LOG_DIR"] = str(log_dir)
|
|
35
|
+
rclpy.init()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _srv(node, service):
|
|
39
|
+
"""'talker', 'get_parameters' -> '/talker/get_parameters'."""
|
|
40
|
+
return f"/{node.strip('/')}/{service}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_test_probes = None # while a test runs (plugin.py): the RosProbes it made
|
|
44
|
+
_count = itertools.count() # node names unique within the process
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RosProbe:
|
|
48
|
+
"""An rclpy node the tests use to poke the system. Every wait calls
|
|
49
|
+
check_health(), so a dead process fails it now, not at the timeout.
|
|
50
|
+
|
|
51
|
+
cls.probe = RosProbe(log_dir=cls.procs.log_dir) # setup_class; destroy() in teardown
|
|
52
|
+
with RosProbe() as probe: # in a test
|
|
53
|
+
|
|
54
|
+
One made in a test is destroyed after it, 'with' or not. rclpy.init()
|
|
55
|
+
is done by the first RosProbe of the run; 'log_dir': its ROS logs go to
|
|
56
|
+
'<log_dir>/ros_probe' (that first RosProbe's only)."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, name="test_probe", log_dir=None):
|
|
59
|
+
ros_init(os.path.join(log_dir, "ros_probe") if log_dir else None)
|
|
60
|
+
n = next(_count)
|
|
61
|
+
self.node = rclpy.create_node(f"{name}_{os.getpid()}" + (f"_{n}" if n else ""))
|
|
62
|
+
self._tf = None # (Buffer, TransformListener), on the first wait_for_transform
|
|
63
|
+
if _test_probes is not None:
|
|
64
|
+
_test_probes.append(self)
|
|
65
|
+
|
|
66
|
+
def __enter__(self):
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *exc):
|
|
70
|
+
self.destroy()
|
|
71
|
+
|
|
72
|
+
def destroy(self):
|
|
73
|
+
"""Destroy the node; a second call does nothing."""
|
|
74
|
+
if self.node is not None:
|
|
75
|
+
if self._tf:
|
|
76
|
+
self._tf[1].unregister()
|
|
77
|
+
self.node.destroy_node()
|
|
78
|
+
self.node = None
|
|
79
|
+
|
|
80
|
+
def spin_until(self, done, timeout, what):
|
|
81
|
+
deadline = time.monotonic() + timeout
|
|
82
|
+
while time.monotonic() < deadline:
|
|
83
|
+
rclpy.spin_once(self.node, timeout_sec=0.1)
|
|
84
|
+
check_health()
|
|
85
|
+
result = done()
|
|
86
|
+
if result:
|
|
87
|
+
return result
|
|
88
|
+
raise AssertionError(f"timed out after {timeout}s waiting for {what}")
|
|
89
|
+
|
|
90
|
+
def nodes(self):
|
|
91
|
+
return set(self.node.get_node_names())
|
|
92
|
+
|
|
93
|
+
def wait_for_nodes(self, names, timeout=20.0):
|
|
94
|
+
self.spin_until(
|
|
95
|
+
lambda: set(names) <= self.nodes(), timeout, f"nodes {sorted(names)}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def wait_for_nodes_gone(self, names, timeout=10.0):
|
|
99
|
+
self.spin_until(
|
|
100
|
+
lambda: not set(names) & self.nodes(),
|
|
101
|
+
timeout,
|
|
102
|
+
f"nodes {sorted(names)} to leave the graph",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def topic_types(self):
|
|
106
|
+
return dict(self.node.get_topic_names_and_types())
|
|
107
|
+
|
|
108
|
+
def wait_for_topics(self, names, timeout=10.0):
|
|
109
|
+
"""The topic types, once every topic in 'names' has a type."""
|
|
110
|
+
if not names:
|
|
111
|
+
return self.topic_types()
|
|
112
|
+
try:
|
|
113
|
+
|
|
114
|
+
def have_all():
|
|
115
|
+
types = self.topic_types()
|
|
116
|
+
return set(names) <= set(types) and types
|
|
117
|
+
|
|
118
|
+
return self.spin_until(have_all, timeout, f"topics {sorted(names)}")
|
|
119
|
+
except AssertionError as exc:
|
|
120
|
+
raise AssertionError(f"{exc}; have {sorted(self.topic_types())}") from None
|
|
121
|
+
|
|
122
|
+
def wait_for_messages(
|
|
123
|
+
self, msg_type, topic, count=1, timeout=5.0, qos=ANY_PUBLISHER
|
|
124
|
+
):
|
|
125
|
+
received = []
|
|
126
|
+
sub = self.node.create_subscription(msg_type, topic, received.append, qos)
|
|
127
|
+
try:
|
|
128
|
+
self.spin_until(
|
|
129
|
+
lambda: len(received) >= count, timeout, f"{count} on {topic}"
|
|
130
|
+
)
|
|
131
|
+
return received[:count]
|
|
132
|
+
finally:
|
|
133
|
+
self.node.destroy_subscription(sub)
|
|
134
|
+
|
|
135
|
+
def rate(self, msg_type, topic, seconds=2.0, qos=ANY_PUBLISHER):
|
|
136
|
+
"""Messages per second on 'topic', counted for 'seconds'."""
|
|
137
|
+
received = []
|
|
138
|
+
sub = self.node.create_subscription(msg_type, topic, received.append, qos)
|
|
139
|
+
try:
|
|
140
|
+
self.spin_until(lambda: received, 5.0, f"a message on {topic}")
|
|
141
|
+
received.clear()
|
|
142
|
+
end = time.monotonic() + seconds
|
|
143
|
+
self.spin_until(lambda: time.monotonic() >= end, seconds + 1, "the count")
|
|
144
|
+
return len(received) / seconds
|
|
145
|
+
finally:
|
|
146
|
+
self.node.destroy_subscription(sub)
|
|
147
|
+
|
|
148
|
+
def services(self):
|
|
149
|
+
"""{service: [types]} in the graph."""
|
|
150
|
+
return dict(self.node.get_service_names_and_types())
|
|
151
|
+
|
|
152
|
+
def wait_for_services(self, names, timeout=10.0):
|
|
153
|
+
"""The service types, once every service in 'names' is in the graph."""
|
|
154
|
+
|
|
155
|
+
def have_all():
|
|
156
|
+
types = self.services()
|
|
157
|
+
return set(names) <= set(types) and types
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
return self.spin_until(have_all, timeout, f"services {sorted(names)}")
|
|
161
|
+
except AssertionError as exc:
|
|
162
|
+
raise AssertionError(f"{exc}; have {sorted(self.services())}") from None
|
|
163
|
+
|
|
164
|
+
def call(self, srv_type, name, request=None, timeout=5.0, **fields):
|
|
165
|
+
"""Call service 'name' once it is up; the reply. The request, or
|
|
166
|
+
its fields: call(AddTwoInts, "add_two_ints", a=2, b=40)."""
|
|
167
|
+
if request is None:
|
|
168
|
+
request = srv_type.Request(**fields)
|
|
169
|
+
elif fields:
|
|
170
|
+
raise TypeError("call(): a request or its fields, not both")
|
|
171
|
+
client = self.node.create_client(srv_type, name)
|
|
172
|
+
try:
|
|
173
|
+
self.spin_until(client.service_is_ready, timeout, f"service {name}")
|
|
174
|
+
future = client.call_async(request)
|
|
175
|
+
self.spin_until(future.done, timeout, f"a reply from {name}")
|
|
176
|
+
return future.result()
|
|
177
|
+
finally:
|
|
178
|
+
self.node.destroy_client(client)
|
|
179
|
+
|
|
180
|
+
def wait_for_transform(self, target, source, timeout=10.0):
|
|
181
|
+
"""The latest transform from frame 'source' to frame 'target' (a
|
|
182
|
+
TransformStamped), once tf has it: wait_for_transform("map",
|
|
183
|
+
"base_link") is base_link's pose in map. Chains through the tree.
|
|
184
|
+
The probe listens to tf from its first call on; static transforms
|
|
185
|
+
are latched, so they arrive whenever it starts."""
|
|
186
|
+
if self._tf is None:
|
|
187
|
+
from tf2_ros import (
|
|
188
|
+
Buffer,
|
|
189
|
+
TransformListener,
|
|
190
|
+
) # only tests that use tf need it
|
|
191
|
+
|
|
192
|
+
buffer = Buffer()
|
|
193
|
+
self._tf = (buffer, TransformListener(buffer, self.node))
|
|
194
|
+
buffer = self._tf[0]
|
|
195
|
+
|
|
196
|
+
def lookup():
|
|
197
|
+
if buffer.can_transform(target, source, rclpy.time.Time()):
|
|
198
|
+
return buffer.lookup_transform(target, source, rclpy.time.Time())
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
return self.spin_until(lookup, timeout, f"transform {source} -> {target}")
|
|
202
|
+
except AssertionError as exc:
|
|
203
|
+
frames = yaml.safe_load(buffer.all_frames_as_yaml()) or {}
|
|
204
|
+
have = ", ".join(
|
|
205
|
+
sorted(f"{f['parent']} -> {child}" for child, f in frames.items())
|
|
206
|
+
)
|
|
207
|
+
raise AssertionError(f"{exc}; have {have or 'no transforms'}") from None
|
|
208
|
+
|
|
209
|
+
# Another node's parameters, through its parameter services. 'node': its
|
|
210
|
+
# name, "talker" or "/ns/talker".
|
|
211
|
+
|
|
212
|
+
def get_params(self, node, names, timeout=5.0):
|
|
213
|
+
"""{name: value} (Python values); fails naming any 'node' hasn't set."""
|
|
214
|
+
reply = self.call(
|
|
215
|
+
GetParameters,
|
|
216
|
+
_srv(node, "get_parameters"),
|
|
217
|
+
timeout=timeout,
|
|
218
|
+
names=list(names),
|
|
219
|
+
)
|
|
220
|
+
missing = [
|
|
221
|
+
n
|
|
222
|
+
for n, v in zip(names, reply.values)
|
|
223
|
+
if v.type == ParameterType.PARAMETER_NOT_SET
|
|
224
|
+
]
|
|
225
|
+
assert not missing, f"{node}: no parameter {', '.join(missing)}"
|
|
226
|
+
return {n: parameter_value_to_python(v) for n, v in zip(names, reply.values)}
|
|
227
|
+
|
|
228
|
+
def get_param(self, node, name, timeout=5.0):
|
|
229
|
+
return self.get_params(node, [name], timeout)[name]
|
|
230
|
+
|
|
231
|
+
def set_params(self, node, values, timeout=5.0):
|
|
232
|
+
"""Set {name: value} on 'node', each on its own; fails with the
|
|
233
|
+
node's reason for each one it rejects."""
|
|
234
|
+
msgs = [Parameter(n, value=v).to_parameter_msg() for n, v in values.items()]
|
|
235
|
+
reply = self.call(
|
|
236
|
+
SetParameters,
|
|
237
|
+
_srv(node, "set_parameters"),
|
|
238
|
+
timeout=timeout,
|
|
239
|
+
parameters=msgs,
|
|
240
|
+
)
|
|
241
|
+
rejected = [
|
|
242
|
+
f"{n}: {r.reason or 'rejected'}"
|
|
243
|
+
for n, r in zip(values, reply.results)
|
|
244
|
+
if not r.successful
|
|
245
|
+
]
|
|
246
|
+
assert not rejected, f"{node} rejected " + "; ".join(rejected)
|
|
247
|
+
|
|
248
|
+
def set_param(self, node, name, value, timeout=5.0):
|
|
249
|
+
self.set_params(node, {name: value}, timeout)
|
|
250
|
+
|
|
251
|
+
def list_params(self, node, timeout=5.0):
|
|
252
|
+
"""The names of 'node''s parameters."""
|
|
253
|
+
reply = self.call(
|
|
254
|
+
ListParameters, _srv(node, "list_parameters"), timeout=timeout
|
|
255
|
+
)
|
|
256
|
+
return sorted(reply.result.names)
|
|
257
|
+
|
|
258
|
+
def publish_until(self, msg_type, topic, msg, done, timeout=5.0, what=None):
|
|
259
|
+
"""Publish 'msg' every half second until done() -- a fresh publisher's
|
|
260
|
+
first messages can go out before a subscriber has matched it."""
|
|
261
|
+
pub = self.node.create_publisher(msg_type, topic, 10)
|
|
262
|
+
last = [0.0]
|
|
263
|
+
|
|
264
|
+
def step():
|
|
265
|
+
if time.monotonic() - last[0] > 0.5:
|
|
266
|
+
pub.publish(msg)
|
|
267
|
+
last[0] = time.monotonic()
|
|
268
|
+
return done()
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
return self.spin_until(step, timeout, what or f"a reaction to {topic}")
|
|
272
|
+
finally:
|
|
273
|
+
self.node.destroy_publisher(pub)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lte-launch-test
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ROS 2 launch tests on pytest, a launch_testing replacement on libtmux_easy
|
|
5
|
+
Home-page: https://github.com/bigdiehl/lte_launch_test
|
|
6
|
+
Author: Spencer Diehl
|
|
7
|
+
Author-email: docdiehl@gmail.com
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Keywords: ros2,ros,launch,testing,pytest,launch_testing
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: libtmux-easy>=0.2.0
|
|
21
|
+
Requires-Dist: pytest<9,>=7.0
|
|
22
|
+
Requires-Dist: pyyaml
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# lte_launch_test
|
|
26
|
+
|
|
27
|
+
ROS 2 launch tests on pytest: a replacement for `ros2 launch_test` /
|
|
28
|
+
`launch_testing`. [libtmux_easy](https://github.com/nasa-jpl/libtmux_easy)'s pytest plugin runs the
|
|
29
|
+
processes (subprocesses by default, tmux with `--tmux`); this package adds
|
|
30
|
+
the ROS parts. libtmux_easy stays ROS-agnostic.
|
|
31
|
+
|
|
32
|
+
Processes are shell commands; a launch file is one too (`ros2 launch
|
|
33
|
+
file.py`). Design and `launch_testing` parity: [docs/](docs/README.md).
|
|
34
|
+
|
|
35
|
+
ROS 2 Humble and Jazzy.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
| how | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| pip | `pip install lte-launch-test` in a venv made with `--system-site-packages` (it needs ROS's `rclpy`) |
|
|
42
|
+
| `.deb` (Ubuntu 24.04) | from the GitHub Releases, with libtmux_easy's |
|
|
43
|
+
| colcon | in your workspace's `src/`, with `package.xml` |
|
|
44
|
+
|
|
45
|
+
Details: [docs/packaging.md](docs/packaging.md).
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
cd examples
|
|
51
|
+
./run.sh test_01_topic.py -v # start here; numbered simplest first
|
|
52
|
+
LTE_DEMO_CRASH=1 ./run.sh test_09_tour.py -v # a node dies mid-scenario
|
|
53
|
+
./run.sh # all of them
|
|
54
|
+
./run.sh --tmux # the dashboard; --no-attach: detached tmux
|
|
55
|
+
./run.sh --keep-open # leave every node running after the run
|
|
56
|
+
./run.sh failing # each fails on purpose
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`run.sh` uses ROS as sourced (else `/opt/ros/$ROS_DISTRO`, default Jazzy), puts `src/` on the path, and uses
|
|
60
|
+
`../libtmux_easy/src` if libtmux_easy isn't installed.
|
|
61
|
+
|
|
62
|
+
## Use
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# conftest.py
|
|
66
|
+
pytest_plugins = ["lte_launch_test.plugin"]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| name | what |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `lte_launch_test.plugin` | a per-run `ROS_DOMAIN_ID` (reserved via `domain_coordinator`) + localhost discovery; every process inherits pytest's environment (ROS and overlays), runs in the rootdir, logs ROS to `$LTE_LOG_DIR/ros`; a `RosProbe` made in a test is destroyed after it |
|
|
72
|
+
| `RosProbe(log_dir=)` | rclpy node (does `rclpy.init()` first; its ROS logs in `<log_dir>/ros_probe`); `with RosProbe() as probe:` in a test, or `RosProbe()` in `setup_class` and `destroy()` in teardown; subscribes with QoS that matches any publisher; waits (`wait_for_nodes`, `wait_for_topics`, `wait_for_services`, `wait_for_messages`, `rate`, `call`, `publish_until`) call `check_health()`, so a death fails them at once |
|
|
73
|
+
| `RosProbe` services, params | `call(Srv, name, a=2, b=40)` (or a request); another node's parameters: `get_param(node, name)`, `get_params`, `set_param(node, name, value)`, `set_params(node, {...})` (fails with the node's reason if rejected), `list_params(node)` |
|
|
74
|
+
| `RosProbe` tf | `wait_for_transform(target, source)`: a `TransformStamped` once tf has it (chains through the tree); the failure lists the transforms it has |
|
|
75
|
+
| `launch_death(allow=)` | `fail_on` check: a node died inside `ros2 launch` (non-zero exit; SIGINT/SIGTERM and exit 0 are fine) |
|
|
76
|
+
| `composable_failure(allow=)` | `fail_on` check: a composable node failed to load (launch's `Failed to load node`, or the container's `Failed to find class` / `Could not find requested resource` / `Failed to load library`) |
|
|
77
|
+
| `wait_for_bag_topics(procs, name, topics, timeout)` | wait for `ros2 bag record` process `name` to subscribe to all its topics; fails naming the missing ones |
|
|
78
|
+
| `check_bag(bag_dir, min_messages)` | after the recorder stopped: fail on each topic below its `{topic: n}` count (from `metadata.yaml`); `bag_counts()`: `{topic: n}` |
|
|
79
|
+
| `Processes`, `check_health`, `arg`, `parametrize` | re-exported from `libtmux_easy.testing` |
|
|
80
|
+
|
|
81
|
+
## colcon
|
|
82
|
+
|
|
83
|
+
Verified with `colcon test` on Jazzy, for both build types. Put
|
|
84
|
+
`test/conftest.py` (`pytest_plugins = [...]`) next to the tests; libtmux_easy and
|
|
85
|
+
lte_launch_test must be importable (installed, or on `PYTHONPATH`).
|
|
86
|
+
|
|
87
|
+
| build type | add |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `ament_python` | nothing: colcon runs pytest on `test/` |
|
|
90
|
+
| `ament_cmake` | `find_package(ament_cmake_pytest REQUIRED)` and `ament_add_pytest_test(test_talker test/test_talker.py TIMEOUT 120)` |
|
|
91
|
+
|
|
92
|
+
## Layout
|
|
93
|
+
|
|
94
|
+
| path | what |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `src/lte_launch_test/` | the package |
|
|
97
|
+
| `docs/` | design, `launch_testing` parity, packaging, [future development](docs/FUTURE_DEVELOPMENT.md) |
|
|
98
|
+
| `examples/` | numbered example tests, simplest first; `failing/` fails on purpose. See [examples/](examples/README.md) |
|
|
99
|
+
| `test/` | `test/run.sh`: every failure check, unit and live (launch deaths, container crash, composable load errors, bag topics/counts, probe waits, services, params, tf), and that `examples/failing` fails as documented |
|
|
100
|
+
| `debian/`, `package.xml`, `setup.cfg` | the `.deb`, colcon, pip |
|
|
101
|
+
| `.github/workflows/` | CI (Humble, Jazzy), release |
|
|
102
|
+
|
|
103
|
+
## Notes
|
|
104
|
+
|
|
105
|
+
- A failed test stops its class's processes at once (SIGINT, escalating;
|
|
106
|
+
`on_abort=` first, if given) and skips the rest of the class.
|
|
107
|
+
`--keep-open` keeps them instead.
|
|
108
|
+
- Logs: each process's output and ROS logs go to pytest's tmp dir, printed
|
|
109
|
+
at the end as `process logs: <dir>`.
|
|
110
|
+
- A node that should exit 0 on SIGINT must catch `KeyboardInterrupt` /
|
|
111
|
+
`ExternalShutdownException` around `rclpy.spin()` (see `examples/nodes/counter_node.py`).
|
|
112
|
+
- A stopped node's name can linger in the graph briefly. Give per-test
|
|
113
|
+
nodes names of their own (`-r __node:=...`).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
package.xml
|
|
5
|
+
pyproject.toml
|
|
6
|
+
setup.cfg
|
|
7
|
+
setup.py
|
|
8
|
+
resource/lte_launch_test
|
|
9
|
+
src/lte_launch_test/__init__.py
|
|
10
|
+
src/lte_launch_test/bag.py
|
|
11
|
+
src/lte_launch_test/checks.py
|
|
12
|
+
src/lte_launch_test/plugin.py
|
|
13
|
+
src/lte_launch_test/probe.py
|
|
14
|
+
src/lte_launch_test.egg-info/PKG-INFO
|
|
15
|
+
src/lte_launch_test.egg-info/SOURCES.txt
|
|
16
|
+
src/lte_launch_test.egg-info/dependency_links.txt
|
|
17
|
+
src/lte_launch_test.egg-info/requires.txt
|
|
18
|
+
src/lte_launch_test.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lte_launch_test
|