Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support CLI commands testing #279

Merged
merged 6 commits into from
Oct 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 4 additions & 48 deletions launch_testing/launch_testing/asserts/assert_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,56 +20,12 @@
further reference.
"""


import os

from osrf_pycommon.terminal_color import remove_ansi_escape_sequences

from ..tools.text import build_text_match
from ..util import resolveProcesses


def normalize_lineseps(lines):
r"""Normalize and then return the given lines to all use '\n'."""
lines = lines.replace(os.linesep, '\n')
# This happens (even on Linux and macOS) when capturing I/O from an
# emulated tty.
lines = lines.replace('\r\n', '\n')
return lines


def get_matching_function(expected_output):
if isinstance(expected_output, (list, tuple)):
if len(expected_output) > 0:
if isinstance(expected_output[0], str):
def _match(expected, actual):
lines = actual.splitlines()
for pattern in expected:
if pattern not in lines:
return False
index = lines.index(pattern)
lines = lines[index + 1:]
return True
return _match
if hasattr(expected_output[0], 'search'):
def _match(expected, actual):
start = 0
actual = normalize_lineseps(actual)
for pattern in expected:
match = pattern.search(actual, start)
if match is None:
return False
start = match.end()
return True
return _match
elif isinstance(expected_output, str):
return lambda expected, actual: expected in actual
elif hasattr(expected_output, 'search'):
return lambda expected, actual: (
expected.search(normalize_lineseps(actual)) is not None
)
raise ValueError('Unknown format for expected output')


def assertInStdout(proc_output,
expected_output,
process,
Expand All @@ -86,7 +42,7 @@ def assertInStdout(proc_output,
:type proc_output: An launch_testing.IoHandler

:param expected_output: The output to search for
:type expected_output: string or regex pattern or list of the aforementioned types
:type expected_output: string or regex pattern or a list of the aforementioned types

:param process: The process whose output will be searched
:type process: A string (search by process name) or a launch.actions.ExecuteProcess object
Expand Down Expand Up @@ -118,7 +74,7 @@ def assertInStdout(proc_output,
if output_filter is not None:
if not callable(output_filter):
raise ValueError('output_filter is not callable')
output_match = get_matching_function(expected_output)
output_match = build_text_match(expected_output)

for proc in resolved_procs: # Nominally just one matching proc
full_output = ''.join(
Expand All @@ -128,7 +84,7 @@ def assertInStdout(proc_output,
full_output = remove_ansi_escape_sequences(full_output)
if output_filter is not None:
full_output = output_filter(full_output)
if output_match(expected_output, full_output):
if output_match(full_output) is not None:
break
else:
names = ', '.join(sorted(p.process_details['name'] for p in resolved_procs))
Expand Down
19 changes: 15 additions & 4 deletions launch_testing/launch_testing/io_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ def __init__(self):
self._sequence_list = [] # A time-ordered list of IO from all processes
self._process_name_dict = {} # A dict of time ordered lists of IO key'd by the process

def track(self, process_name):
if process_name not in self._process_name_dict:
self._process_name_dict[process_name] = []

def append(self, process_io):
self._sequence_list.append(process_io)

if process_io.process_name not in self._process_name_dict:
self._process_name_dict[process_io.process_name] = []

self._process_name_dict[process_io.process_name].append(process_io)

def __iter__(self):
Expand All @@ -56,7 +58,7 @@ def processes(self):

:returns [launch.actions.ExecuteProcess]:
"""
return [val[0].action for val in self._process_name_dict.values()]
return [val[0].action for val in self._process_name_dict.values() if len(val) > 0]

def process_names(self):
"""
Expand All @@ -79,7 +81,7 @@ def __getitem__(self, key):
return list(self._process_name_dict[key.process_details['name']])


class ActiveIoHandler(IoHandler):
class ActiveIoHandler:
"""
Holds stdout captured from running processes.

Expand All @@ -93,6 +95,15 @@ def __init__(self):
# by composition so we can still give out the unsynchronized version
self._io_handler = IoHandler()

@property
def io_event(self):
return self._sync_lock

def track(self, process_name):
with self._sync_lock:
self._io_handler.track(process_name)
self._sync_lock.notify()

def append(self, process_io):
with self._sync_lock:
self._io_handler.append(process_io)
Expand Down
23 changes: 16 additions & 7 deletions launch_testing/launch_testing/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ def __init__(self,
pre_shutdown_tests,
post_shutdown_tests):
self.name = name
if not hasattr(test_description_function, '__markers__'):
test_description_function.__markers__ = {}
self._test_description_function = test_description_function
self.normalized_test_description = _normalize_ld(test_description_function)

Expand All @@ -100,6 +102,10 @@ def __init__(self,
setattr(tc, '_testMethodName', new_name)
setattr(tc, new_name, test_method)

@property
def markers(self):
return self._test_description_function.__markers__

def bind(self, tests, injected_attributes={}, injected_args={}):
"""
Bind injected_attributes and injected_args to tests.
Expand Down Expand Up @@ -176,13 +182,16 @@ class _loader(unittest.TestLoader):
"""TestLoader selectively loads pre-shutdown or post-shutdown tests."""

def loadTestsFromTestCase(self, testCaseClass):

if getattr(testCaseClass, '__post_shutdown_test__', False) == load_post_shutdown:
cases = super(_loader, self).loadTestsFromTestCase(testCaseClass)
# Isolate test classes instances on a per parameterization basis
cases = super(_loader, self).loadTestsFromTestCase(
type(testCaseClass.__name__, (testCaseClass,), {
'__module__': testCaseClass.__module__
})
)
return cases
else:
# Empty test suites will be ignored by the test runner
return self.suiteClass()
# Empty test suites will be ignored by the test runner
return self.suiteClass()

return _loader()

Expand Down Expand Up @@ -224,9 +233,9 @@ def _bind_test_args_to_tests(context, test_suite):


def _partially_bind_matching_args(unbound_function, arg_candidates):
function_arg_names = inspect.getfullargspec(unbound_function).args
function_args = inspect.signature(unbound_function).parameters
# We only want to bind the part of the context matches the test args
matching_args = {k: v for (k, v) in arg_candidates.items() if k in function_arg_names}
matching_args = {k: v for (k, v) in arg_candidates.items() if k in function_args}
return functools.partial(unbound_function, **matching_args)


Expand Down
41 changes: 41 additions & 0 deletions launch_testing/launch_testing/markers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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 functools


def keep_alive(test_description):
"""Mark a test launch description to be kept alive after fixture processes' termination."""
if not hasattr(test_description, '__markers__'):
test_description.__markers__ = {}
test_description.__markers__['keep_alive'] = True
return test_description


def retry_on_failure(*, times):
"""Mark a test case to be retried up to `times` on AssertionError."""
assert times > 0

def _decorator(func):
@functools.wraps(func)
def _wrapper(*args, **kwargs):
n = times
while n > 1:
try:
return func(*args, **kwargs)
except AssertionError:
n -= 1
return func(*args, **kwargs)
return _wrapper
return _decorator
7 changes: 5 additions & 2 deletions launch_testing/launch_testing/proc_info_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def __getitem__(self, key):
return self._proc_info[key]


class ActiveProcInfoHandler(ProcInfoHandler):
class ActiveProcInfoHandler:
"""Allows tests to wait on process termination before proceeding."""

def __init__(self):
Expand All @@ -79,6 +79,10 @@ def __init__(self):
# by composition so we can still give out the unsynchronized version
self._proc_info_handler = ProcInfoHandler()

@property
def proc_event(self):
return self._sync_lock

def append(self, process_info):
with self._sync_lock:
self._proc_info_handler.append(process_info)
Expand Down Expand Up @@ -126,7 +130,6 @@ def proc_is_shutdown():
cmd_args=cmd_args,
strict_proc_matching=True
)[0]

process_event = self._proc_info_handler[resolved_process]
return isinstance(process_event, ProcessExited)
except NoMatchingProcessException:
Expand Down
4 changes: 3 additions & 1 deletion launch_testing/launch_testing/pytest/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from unittest import TestCase

import pytest

from ..loader import LoadTestsFromPythonModule
Expand Down Expand Up @@ -64,7 +66,7 @@ def repr_failure(self, excinfo):
)
for test_run, test_result in excinfo.value.results.items()
for test_case, _ in (test_result.errors + test_result.failures)
if not test_result.wasSuccessful()
if isinstance(test_case, TestCase) and not test_result.wasSuccessful()
}) if excinfo.value.results else ''
return super().repr_failure(excinfo)

Expand Down
11 changes: 10 additions & 1 deletion launch_testing/launch_testing/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ def run(self):
RegisterEventHandler(
OnProcessStart(on_start=lambda info, unused: proc_info.append(info))
),
RegisterEventHandler(
OnProcessStart(
on_start=lambda info, unused: proc_output.track(info.process_name)
)
),
RegisterEventHandler(
OnProcessExit(on_exit=lambda info, unused: proc_info.append(info))
),
Expand All @@ -148,7 +153,11 @@ def run(self):
)

self._test_tr.start() # Run the tests on another thread
self._launch_service.run() # This will block until the test thread stops it

# This will block until the test thread stops it
self._launch_service.run(
shutdown_when_idle=not self._test_run.markers.get('keep_alive', False)
)

if not self._tests_completed.wait(timeout=0):
# LaunchService.run returned before the tests completed. This can be because the user
Expand Down
6 changes: 5 additions & 1 deletion launch_testing/launch_testing/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from .launchers import launch_process
from .output import basic_output_filter
from .output import expect_output
from .output import expected_output_from_file
from .process import launch_process
from .process import ProcessProxy


__all__ = [
'basic_output_filter',
'expect_output',
'expected_output_from_file',
'launch_process',
'ProcessProxy'
]
43 changes: 0 additions & 43 deletions launch_testing/launch_testing/tools/launchers.py

This file was deleted.

Loading