From 4378eecebde8261d427b23e44498d5960462ba92 Mon Sep 17 00:00:00 2001 From: Shane Loretz Date: Mon, 15 Apr 2019 15:05:26 -0700 Subject: [PATCH 1/3] SLORETZ: partial launch_test composition support Signed-off-by: Shane Loretz --- test_launch_ros/package.xml | 1 + test_launch_ros/setup.py | 4 ++ test_launch_ros/test/rostest/composition.py | 60 +++++++++++++++++++ test_launch_ros/test_launch_ros/__init__.py | 0 .../mock_composable_container.py | 16 +++++ 5 files changed, 81 insertions(+) create mode 100644 test_launch_ros/test/rostest/composition.py create mode 100644 test_launch_ros/test_launch_ros/__init__.py create mode 100644 test_launch_ros/test_launch_ros/mock_composable_container.py diff --git a/test_launch_ros/package.xml b/test_launch_ros/package.xml index 9ce13b86..03816ca0 100644 --- a/test_launch_ros/package.xml +++ b/test_launch_ros/package.xml @@ -14,6 +14,7 @@ ament_pep257 demo_nodes_py launch_ros + launch_testing_ros launch_xml launch_yaml python3-pytest diff --git a/test_launch_ros/setup.py b/test_launch_ros/setup.py index f8bdb3a6..5dbcffd6 100644 --- a/test_launch_ros/setup.py +++ b/test_launch_ros/setup.py @@ -37,4 +37,8 @@ ), license='Apache License, Version 2.0', tests_require=['pytest'], + entry_points={ + 'console_scripts': [ + 'mock-composable-container=test_launch_ros.mock_composable_container:main'], + } ) diff --git a/test_launch_ros/test/rostest/composition.py b/test_launch_ros/test/rostest/composition.py new file mode 100644 index 00000000..2e66d7cf --- /dev/null +++ b/test_launch_ros/test/rostest/composition.py @@ -0,0 +1,60 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest + +import ament_index_python +from launch import LaunchDescription +from launch import LaunchService +from launch.actions import ExecuteProcess +from launch.actions import OpaqueFunction +import launch_testing +import launch_testing.asserts + + +def generate_test_description(ready_fn): + launch_description = LaunchDescription() + + launch_description.add_action( + ExecuteProcess( + cmd=[ + os.path.join( + ament_index_python.get_package_prefix('test_launch_ros'), + 'bin', + 'mock-composable-container'), + ], + ) + ) + + launch_description.add_action( + OpaqueFunction(function=lambda context: ready_fn()) + ) + return launch_description, locals() + + +class TestComposition(unittest.TestCase): + + def test_hello_world(self): + raise Exception(repr(self.proc_info._proc_info_handler._proc_info)) + proc = self.proc_info.processes()[0] + launch_testing.asserts.assertInStdout(self.proc_output, 'Hello world', proc) + + +# @launch_testing.post_shutdown_test() +# class TestTwoExecutablesAfterShutdown(unittest.TestCase): +# +# def @TEST_NAME@(self, executable_under_test): +# """Test that the executable under test finished cleanly.""" +# launch_testing.asserts.assertExitCodes(self.proc_info, process=executable_under_test) diff --git a/test_launch_ros/test_launch_ros/__init__.py b/test_launch_ros/test_launch_ros/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_launch_ros/test_launch_ros/mock_composable_container.py b/test_launch_ros/test_launch_ros/mock_composable_container.py new file mode 100644 index 00000000..eeeba2f0 --- /dev/null +++ b/test_launch_ros/test_launch_ros/mock_composable_container.py @@ -0,0 +1,16 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +def main(): + print('Hello world') From e43d3504b69b697b3cf052f3399d01760ff6b786 Mon Sep 17 00:00:00 2001 From: Shane Loretz Date: Tue, 16 Apr 2019 16:11:15 -0700 Subject: [PATCH 2/3] SLORETZ: can run some composition tests Signed-off-by: Shane Loretz --- test_launch_ros/setup.cfg | 4 + test_launch_ros/test/rostest/composition.py | 60 ------- .../test/rostest/composition.test.py | 169 ++++++++++++++++++ .../mock_composable_container.py | 113 +++++++++++- 4 files changed, 285 insertions(+), 61 deletions(-) create mode 100644 test_launch_ros/setup.cfg delete mode 100644 test_launch_ros/test/rostest/composition.py create mode 100644 test_launch_ros/test/rostest/composition.test.py diff --git a/test_launch_ros/setup.cfg b/test_launch_ros/setup.cfg new file mode 100644 index 00000000..78f135f5 --- /dev/null +++ b/test_launch_ros/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script-dir=$base/lib/test_launch_ros +[install] +install-scripts=$base/lib/test_launch_ros diff --git a/test_launch_ros/test/rostest/composition.py b/test_launch_ros/test/rostest/composition.py deleted file mode 100644 index 2e66d7cf..00000000 --- a/test_launch_ros/test/rostest/composition.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright 2019 Open Source Robotics Foundation, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest - -import ament_index_python -from launch import LaunchDescription -from launch import LaunchService -from launch.actions import ExecuteProcess -from launch.actions import OpaqueFunction -import launch_testing -import launch_testing.asserts - - -def generate_test_description(ready_fn): - launch_description = LaunchDescription() - - launch_description.add_action( - ExecuteProcess( - cmd=[ - os.path.join( - ament_index_python.get_package_prefix('test_launch_ros'), - 'bin', - 'mock-composable-container'), - ], - ) - ) - - launch_description.add_action( - OpaqueFunction(function=lambda context: ready_fn()) - ) - return launch_description, locals() - - -class TestComposition(unittest.TestCase): - - def test_hello_world(self): - raise Exception(repr(self.proc_info._proc_info_handler._proc_info)) - proc = self.proc_info.processes()[0] - launch_testing.asserts.assertInStdout(self.proc_output, 'Hello world', proc) - - -# @launch_testing.post_shutdown_test() -# class TestTwoExecutablesAfterShutdown(unittest.TestCase): -# -# def @TEST_NAME@(self, executable_under_test): -# """Test that the executable under test finished cleanly.""" -# launch_testing.asserts.assertExitCodes(self.proc_info, process=executable_under_test) diff --git a/test_launch_ros/test/rostest/composition.test.py b/test_launch_ros/test/rostest/composition.test.py new file mode 100644 index 00000000..4e15e9bb --- /dev/null +++ b/test_launch_ros/test/rostest/composition.test.py @@ -0,0 +1,169 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest + +from composition_interfaces.srv import LoadNode +from launch import LaunchDescription +from launch import LaunchService +from launch.actions import ExecuteProcess +from launch.actions import OpaqueFunction +from launch.actions import RegisterEventHandler +from launch.event_handlers.on_process_start import OnProcessStart +from launch_ros import get_default_launch_description +from launch_ros.actions import ComposableNodeContainer +from launch_ros.actions import LoadComposableNodes +from launch_ros.descriptions import ComposableNode +import launch_testing +import launch_testing.asserts +from rcl_interfaces.msg import Parameter +from rcl_interfaces.msg import ParameterType + + +def generate_test_description(ready_fn): + # Necessary to get real-time stdout from python processes: + proc_env = os.environ.copy() + proc_env['PYTHONUNBUFFERED'] = '1' + + launch_description = LaunchDescription() + + mock_container = ComposableNodeContainer( + env=proc_env, + node_name='my_container', + node_namespace='/my_ns', + package='test_launch_ros', + node_executable='mock-composable-container', + composable_node_descriptions=[ + ComposableNode(package='fake_package', node_plugin='successfully_load'), + ComposableNode(package='fake_package', node_plugin='fail_to_load'), + ComposableNode( + package='fake_package', node_plugin='node_name', + node_name='my_talker' + ), + ComposableNode( + package='fake_package', node_plugin='node_namespace', + node_namespace='my_namespace' + ), + ComposableNode( + package='fake_package', node_plugin='remap_rules', + remappings=[('~/foo', '/bar')] + ), + ComposableNode( + package='fake_package', node_plugin='parameters', + parameters=[{'foo': {'bar': 'baz'}}] + ), + ComposableNode( + package='fake_package', node_plugin='extra_arguments', + extra_arguments=[{'ping.pong': 5}] + ), + # TODO(sloretz) log level + # ComposableNode( + # package='fake_package', node_plugin='log_level', + # log_level=1 + # ), + ]) + + launch_description.add_action(get_default_launch_description()) + launch_description.add_action(mock_container) + # TODO(sloretz) post-launch composable node actions + # launch_description.add_action( + # RegisterEventHandler( + # event_handler=OnProcessStart( + # target_action=mock_container, + # on_start=[ + # LoadComposableNodes( + # composable_node_descriptions=[ + # ComposableNode( + # package='fake_package', node_plugin='node_name', + # node_name='my_talker' + # ), + # ], + # target_container=mock_container + # ) + # ] + # ) + # ) + # ) + launch_description.add_action( + OpaqueFunction(function=lambda context: ready_fn()) + ) + + return launch_description, {'container': mock_container} + + +class TestComposition(unittest.TestCase): + + def test_successfully_load(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'successfully_load' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_fail_to_load(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'fail_to_load' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_node_name(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'node_name' + request.node_name = 'my_talker' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_node_namespace(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'node_namespace' + request.node_namespace = 'my_namespace' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_remap_rules(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'remap_rules' + request.remap_rules = ['~/foo:=/bar'] + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_parameters(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'parameters' + p = Parameter() + p.name = 'foo.bar' + p.value.string_value = 'baz' + p.value.type = ParameterType.PARAMETER_STRING + request.parameters = [p] + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_extra_arguments(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'extra_arguments' + p = Parameter() + p.name = 'ping.pong' + p.value.integer_value = 5 + p.value.type = ParameterType.PARAMETER_INTEGER + request.extra_arguments = [p] + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + # TODO(sloretz) log level + # def test_custom_log_level(self, container): + # request = LoadNode.Request() + # request.package_name = 'fake_package' + # request.plugin_name = 'log_level' + # request.log_level = 1 + # self.proc_output.assertWaitFor(expected_output=repr(request), process=container) diff --git a/test_launch_ros/test_launch_ros/mock_composable_container.py b/test_launch_ros/test_launch_ros/mock_composable_container.py index eeeba2f0..ea003fd8 100644 --- a/test_launch_ros/test_launch_ros/mock_composable_container.py +++ b/test_launch_ros/test_launch_ros/mock_composable_container.py @@ -12,5 +12,116 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + +from composition_interfaces.srv import LoadNode +import rclpy +from rclpy.node import Node + + +class MockComposableNodeContainer(Node): + + def __enter__(self): + self.__load_service = self.create_service( + LoadNode, '~/_container/load_node', self._on_load_node) + return self + + def __exit__(self, type_, value, traceback): + self.destroy_service(self.__load_service) + self.destroy_node() + + def __init__(self, name, namespace): + """ + Initialize a mock container process. + + :param load_node_responses: responses to a load node request for a given package/plugin + :type load_node_responses: {(str, str): composition_interfaces.LoadNode.Response, ...} + """ + super().__init__(name, namespace=namespace) + + # Canned responses to load service + fail_to_load = LoadNode.Response() + fail_to_load.success = False + fail_to_load.error_message = 'intentional failure' + + successfully_load = LoadNode.Response() + successfully_load.success = True + successfully_load.full_node_name = '/a_nodename' + successfully_load.unique_id = 2 + + node_name = LoadNode.Response() + node_name.success = True + node_name.full_node_name = '/my_talker' + node_name.unique_id = 4 + + node_namespace = LoadNode.Response() + node_namespace.success = True + node_namespace.full_node_name = '/my_namespace/my_talker' + node_namespace.unique_id = 8 + + log_level = LoadNode.Response() + log_level.success = True + log_level.full_node_name = '/a_nodename' + log_level.unique_id = 16 + + remap_rules = LoadNode.Response() + remap_rules.success = True + remap_rules.full_node_name = '/a_nodename' + remap_rules.unique_id = 32 + + parameters = LoadNode.Response() + parameters.success = True + parameters.full_node_name = '/a_nodename' + parameters.unique_id = 64 + + extra_arguments = LoadNode.Response() + extra_arguments.success = True + extra_arguments.full_node_name = '/a_nodename' + extra_arguments.unique_id = 128 + + self.__load_node_responses = { + ('fake_package', 'fail_to_load'): fail_to_load, + ('fake_package', 'successfully_load'): successfully_load, + ('fake_package', 'node_name'): node_name, + ('fake_package', 'node_namespace'): node_namespace, + ('fake_package', 'log_level'): log_level, + ('fake_package', 'remap_rules'): remap_rules, + ('fake_package', 'parameters'): parameters, + ('fake_package', 'extra_arguments'): extra_arguments, + } + + self.unexpected_request = False + + def _on_load_node(self, request, response): + key = (request.package_name, request.plugin_name) + if key not in self.__load_node_responses: + self.unexpected_request = True + unexpected_load = LoadNode.Response() + unexpected_load.success = False + unexpected_load.error_message = 'unexpected load request' + return response + else: + print(repr(request)) + return self.__load_node_responses[key] + return response + + def main(): - print('Hello world') + rclpy.init() + container = MockComposableNodeContainer(name='mock_container', namespace='/') + with container: + try: + rclpy.spin(container) + except KeyboardInterrupt: + print('Got SIGINT, shutting down') + except: + import traceback + traceback.print_exc() + if container.unexpected_request: + sys.stderr.write('failing due to unexpected request\n') + sys.exit(1) + rclpy.shutdown() + + +if __name__ == '__main__': + main() From 5e9592aaccb02206b295f51c4e48c1c35f00ff3a Mon Sep 17 00:00:00 2001 From: Shane Loretz Date: Wed, 17 Apr 2019 08:32:39 -0700 Subject: [PATCH 3/3] Add test for launching nodes on an event Signed-off-by: Shane Loretz --- .../test/rostest/composition.test.py | 44 +++++++++++-------- .../mock_composable_container.py | 6 +++ 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/test_launch_ros/test/rostest/composition.test.py b/test_launch_ros/test/rostest/composition.test.py index 4e15e9bb..992cfe21 100644 --- a/test_launch_ros/test/rostest/composition.test.py +++ b/test_launch_ros/test/rostest/composition.test.py @@ -77,25 +77,24 @@ def generate_test_description(ready_fn): launch_description.add_action(get_default_launch_description()) launch_description.add_action(mock_container) - # TODO(sloretz) post-launch composable node actions - # launch_description.add_action( - # RegisterEventHandler( - # event_handler=OnProcessStart( - # target_action=mock_container, - # on_start=[ - # LoadComposableNodes( - # composable_node_descriptions=[ - # ComposableNode( - # package='fake_package', node_plugin='node_name', - # node_name='my_talker' - # ), - # ], - # target_container=mock_container - # ) - # ] - # ) - # ) - # ) + launch_description.add_action( + RegisterEventHandler( + event_handler=OnProcessStart( + target_action=mock_container, + on_start=[ + LoadComposableNodes( + composable_node_descriptions=[ + ComposableNode( + package='fake_package', node_plugin='node_name_on_event', + node_name='my_talker_on_event' + ), + ], + target_container=mock_container + ) + ] + ) + ) + ) launch_description.add_action( OpaqueFunction(function=lambda context: ready_fn()) ) @@ -124,6 +123,13 @@ def test_custom_node_name(self, container): request.node_name = 'my_talker' self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + def test_custom_node_name_post_launch(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'node_name_on_event' + request.node_name = 'my_talker_on_event' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + def test_custom_node_namespace(self, container): request = LoadNode.Request() request.package_name = 'fake_package' diff --git a/test_launch_ros/test_launch_ros/mock_composable_container.py b/test_launch_ros/test_launch_ros/mock_composable_container.py index ea003fd8..5a280e34 100644 --- a/test_launch_ros/test_launch_ros/mock_composable_container.py +++ b/test_launch_ros/test_launch_ros/mock_composable_container.py @@ -79,6 +79,11 @@ def __init__(self, name, namespace): extra_arguments.full_node_name = '/a_nodename' extra_arguments.unique_id = 128 + node_name_on_event = LoadNode.Response() + node_name_on_event.success = True + node_name_on_event.full_node_name = '/my_talker_on_event' + node_name_on_event.unique_id = 256 + self.__load_node_responses = { ('fake_package', 'fail_to_load'): fail_to_load, ('fake_package', 'successfully_load'): successfully_load, @@ -88,6 +93,7 @@ def __init__(self, name, namespace): ('fake_package', 'remap_rules'): remap_rules, ('fake_package', 'parameters'): parameters, ('fake_package', 'extra_arguments'): extra_arguments, + ('fake_package', 'node_name_on_event'): node_name_on_event, } self.unexpected_request = False