From a7ba4399df82d7248e6fab507b0f30353a0b2cdc Mon Sep 17 00:00:00 2001 From: Francois Budin Date: Mon, 22 Jan 2018 18:46:08 -0500 Subject: [PATCH] New bazel macro to test run installed drake. `install_test()` is a wrapper macro that creates a Python script that is run during the testing phase. The Python script installs `drake` and runs the targets that are listed in `install_tests` in install rules. The Python script that is created will allow to test multiple targets in the install tree without having to install drake multiple times. `install_test()` should be called only once, in `//:*`. All the install tests that have been created in all the `install()` commands in the source tree will be integrated in the Python script that is generated. The targets can either be executables that are installed, or any executable or scripts that is designed to test drake features in the install tree. The Python install_test_helper file (`tools/install/install_test_helper.py`) has been improved to easily create Python scripts that run commands from the install directory. This is convenient when trying to test an executable in the install tree that is run with parameters, or an executable that needs to be killed because it never stops running. --- BUILD.bazel | 11 +- bindings/pydrake/BUILD.bazel | 37 ++--- bindings/pydrake/test/all_install_test.py | 15 +- bindings/pydrake/test/common_install_test.py | 30 ++-- common/BUILD.bazel | 13 +- common/test/resource_tool_installed_test.py | 44 +++--- examples/kuka_iiwa_arm/BUILD.bazel | 39 ++--- .../iiwa_wsg_simulation_installed_test.py | 25 +++ .../test/kuka_plan_runner_installed_test.py | 14 ++ .../test/kuka_simulation_installed_test.py | 17 +- lcmtypes/BUILD.bazel | 19 +-- lcmtypes/test/drake-lcm-spy_install_test.py | 16 +- tools/install/BUILD.bazel | 27 +++- tools/install/install.bzl | 112 ++++++++++++- tools/install/install_test_helper.py | 147 ++++++++++++++---- .../install/test/install_test_helper_test.py | 53 +++++++ tools/install/test/install_tests.py | 30 ++++ tools/workspace/lcm/BUILD.bazel | 22 +-- tools/workspace/lcm/package.BUILD.bazel | 5 +- tools/workspace/lcm/test/install_test.py | 20 --- .../lcm/test/lcm-gen_install_test.py | 19 +++ 21 files changed, 493 insertions(+), 222 deletions(-) mode change 100644 => 100755 bindings/pydrake/test/all_install_test.py mode change 100644 => 100755 bindings/pydrake/test/common_install_test.py mode change 100644 => 100755 common/test/resource_tool_installed_test.py create mode 100755 examples/kuka_iiwa_arm/test/iiwa_wsg_simulation_installed_test.py create mode 100755 examples/kuka_iiwa_arm/test/kuka_plan_runner_installed_test.py mode change 100644 => 100755 examples/kuka_iiwa_arm/test/kuka_simulation_installed_test.py mode change 100644 => 100755 lcmtypes/test/drake-lcm-spy_install_test.py create mode 100644 tools/install/test/install_test_helper_test.py create mode 100644 tools/install/test/install_tests.py delete mode 100644 tools/workspace/lcm/test/install_test.py create mode 100755 tools/workspace/lcm/test/lcm-gen_install_test.py diff --git a/BUILD.bazel b/BUILD.bazel index 7d80381356d3..b86ed6a449ad 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -3,7 +3,7 @@ # This file is named BUILD.bazel instead of the more typical BUILD, so that on # OSX it won't conflict with a build artifacts directory named "build". -load("@drake//tools/install:install.bzl", "install") +load("@drake//tools/install:install.bzl", "install", "install_test") load("//tools/lint:lint.bzl", "add_lint_tests") package( @@ -40,8 +40,11 @@ py_library( srcs = ["__init__.py"], ) +_INSTALL_TEST_COMMANDS = "install_test_commands.py" + install( name = "install", + install_tests_script = _INSTALL_TEST_COMMANDS, docs = ["LICENSE.TXT"], deps = [ "//automotive/models:install_data", @@ -57,4 +60,10 @@ install( ], ) +install_test( + name = "install_test", + src = _INSTALL_TEST_COMMANDS, + data = [":install"], +) + add_lint_tests() diff --git a/bindings/pydrake/BUILD.bazel b/bindings/pydrake/BUILD.bazel index 4b5cd9627f53..60233d74a93f 100644 --- a/bindings/pydrake/BUILD.bazel +++ b/bindings/pydrake/BUILD.bazel @@ -144,6 +144,10 @@ drake_py_library( install( name = "install", + install_tests = [ + ":test/all_install_test.py", + ":test/common_install_test.py", + ], targets = PY_LIBRARIES + [":all_py"], py_dest = PACKAGE_INFO.py_dest, visibility = ["//visibility:public"], @@ -156,17 +160,6 @@ drake_py_test( deps = [":all_py"], ) -drake_py_test( - name = "all_install_test", - size = "medium", - # Increase the timeout so that debug builds are successful. - timeout = "long", - data = ["//:install"], - deps = [ - "//tools/install:install_test_helper", - ], -) - # Test ODR (One Definition Rule). drake_pybind_library( name = "odr_test_module_py", @@ -199,21 +192,6 @@ drake_py_test( deps = [":common_py"], ) -# `//:install` is run in this test to verify that once installed -# pydrake still works. This test is implemented in a separate file from -# common_test to be able to remove files in the sandbox without -# interfering with other tests. -drake_py_test( - name = "common_install_test", - size = "medium", - # Increase the timeout so that debug builds are successful. - timeout = "long", - data = ["//:install"], - deps = [ - "//tools/install:install_test_helper", - ], -) - drake_py_test( name = "forward_diff_test", size = "small", @@ -261,4 +239,9 @@ drake_py_test( ], ) -add_lint_tests() +add_lint_tests( + python_lint_extra_srcs = [ + ":test/all_install_test.py", + ":test/common_install_test.py", + ], +) diff --git a/bindings/pydrake/test/all_install_test.py b/bindings/pydrake/test/all_install_test.py old mode 100644 new mode 100755 index d2dbea56dc3c..37bfa910e8c6 --- a/bindings/pydrake/test/all_install_test.py +++ b/bindings/pydrake/test/all_install_test.py @@ -1,9 +1,9 @@ +#!/usr/bin/env python """ Ensures we can import `pydrake.all` from install. """ import os -import subprocess import sys import unittest @@ -12,19 +12,20 @@ class TestAllInstall(unittest.TestCase): def test_install(self): - # Install into a temporary directory. - tmp_folder = "tmp" - result = install_test_helper.install(tmp_folder, ['lib']) - self.assertEqual(None, result) + # Get install directory. + install_dir = install_test_helper.get_install_dir() # Override PYTHONPATH to only use the installed `pydrake` module. env_python_path = "PYTHONPATH" tool_env = dict(os.environ) tool_env[env_python_path] = os.path.abspath( - os.path.join(tmp_folder, "lib", "python2.7", "site-packages") + os.path.join(install_dir, "lib", "python2.7", "site-packages") ) # Ensure we can import all user-visible modules. script = "import pydrake.all" - subprocess.check_call([sys.executable, "-c", script], env=tool_env) + install_test_helper.check_call( + [install_test_helper.get_python_executable(), "-c", script], + env=tool_env + ) if __name__ == '__main__': diff --git a/bindings/pydrake/test/common_install_test.py b/bindings/pydrake/test/common_install_test.py old mode 100644 new mode 100755 index 631606477829..f652a7c087e9 --- a/bindings/pydrake/test/common_install_test.py +++ b/bindings/pydrake/test/common_install_test.py @@ -1,42 +1,32 @@ -import os -import shutil -import subprocess -import unittest -import sys +#!/usr/bin/env python +import os import install_test_helper +import unittest class TestCommonInstall(unittest.TestCase): def testDrakeFindResourceOrThrowInInstall(self): - # Install into a temporary directory. The temporary directory does not - # need to be removed as bazel tests are run in a scratch space. - tmp_folder = "tmp" - result = install_test_helper.install(tmp_folder, ['lib', 'share']) - self.assertEqual(None, result) # Override PYTHONPATH to only use the installed `pydrake` module. env_python_path = "PYTHONPATH" tool_env = dict(os.environ) tool_env[env_python_path] = os.path.abspath( - os.path.join(tmp_folder, "lib", "python2.7", "site-packages") + os.path.join(install_test_helper.get_install_dir(), + "lib", "python2.7", "site-packages") ) - data_folder = os.path.join(tmp_folder, "share", "drake") - # Call the same Python binary. On Mac, calling the system `python` - # would result in a crash since pydrake was built against brew python. - # On Linux, this will still work and force using - # python2 which should be a link to the actual executable. + data_folder = os.path.join(install_test_helper.get_install_dir(), + "share", "drake") # Calling `pydrake.getDrakePath()` twice verifies that there # is no memory allocation issue in the C code. - output_path = subprocess.check_output( - [sys.executable, + output_path = install_test_helper.check_output( + [install_test_helper.get_python_executable(), "-c", "import pydrake; print(pydrake.getDrakePath());\ import pydrake; print(pydrake.getDrakePath())" ], - cwd='/', # Defeat the "search in parent folders" heuristic. env=tool_env, ).strip() found_install_path = (data_folder in output_path) - self.assertEqual(found_install_path, True) + self.assertTrue(found_install_path) if __name__ == '__main__': diff --git a/common/BUILD.bazel b/common/BUILD.bazel index 1505a5ed084e..50571f271906 100644 --- a/common/BUILD.bazel +++ b/common/BUILD.bazel @@ -419,6 +419,7 @@ drake_py_binary( install( name = "install", + install_tests = [":test/resource_tool_installed_test.py"], targets = [":resource_tool"], # TODO(jwnimmer-tri) The install rule should offer more specific options # for programs not in bin/, and including the workspace and/or package name @@ -989,12 +990,6 @@ drake_py_test( ], ) -drake_py_test( - name = "resource_tool_installed_test", - srcs = ["test/resource_tool_installed_test.py"], - deps = ["//tools/install:install_test_helper"], -) - # TODO(jwnimmer-tri) These tests are currently missing... # - drake_assert_test in fancy variants # - drake_assert_test_compile in fancy variants @@ -1002,4 +997,8 @@ drake_py_test( # - drake_deprecated_test in fancy variants # - cpplint_wrapper_test.py -add_lint_tests() +add_lint_tests( + python_lint_extra_srcs = [ + ":test/resource_tool_installed_test.py", + ], +) diff --git a/common/test/resource_tool_installed_test.py b/common/test/resource_tool_installed_test.py old mode 100644 new mode 100755 index c67a5ab06be4..de5786134bcd --- a/common/test/resource_tool_installed_test.py +++ b/common/test/resource_tool_installed_test.py @@ -1,30 +1,38 @@ +#!/usr/bin/env python + """Performs tests for resource_tool as used _after_ installation. """ import os -import subprocess import unittest import install_test_helper class TestResourceTool(unittest.TestCase): def test_install_and_run(self): - # Install into a temporary directory. - result = install_test_helper.install("tmp", ["libexec"]) - self.assertEqual(None, result) - + install_dir = install_test_helper.get_install_dir() # Create a resource in the temporary directory. - os.makedirs("tmp/share/drake/common/test") - with open("tmp/share/drake/common/test/tmp_resource", "w") as f: - f.write("tmp_resource") + tmp_dir = install_test_helper.create_temporary_dir() - # Verify un-installed copy was removed, so we _know_ it won't be used. - self.assertEqual(os.listdir(os.getcwd()), ["tmp"]) + resource_folder = os.path.join(tmp_dir, "share/drake/") + test_folder = os.path.join(resource_folder, "common/test") + os.makedirs(test_folder) + # Create sentinel file. + sentinel = os.path.join(resource_folder, + ".drake-find_resource-sentinel") + with open(sentinel, "w") as f: + f.write("") + # Create resource file. + resource = os.path.join(test_folder, "tmp_resource") + resource_data = "tmp_resource" + with open(resource, "w") as f: + f.write(resource_data) # Cross-check the resource root environment variable name. env_name = "DRAKE_RESOURCE_ROOT" - resource_tool = "tmp/share/drake/common/resource_tool" - output_name = subprocess.check_output( + resource_tool = os.path.join( + install_dir, "share/drake/common/resource_tool") + output_name = install_test_helper.check_output( [resource_tool, "--print_resource_root_environment_variable_name", ], @@ -33,8 +41,8 @@ def test_install_and_run(self): # Use the installed resource_tool to find a resource. tool_env = dict(os.environ) - tool_env[env_name] = "tmp/share" - absolute_path = subprocess.check_output( + tool_env[env_name] = os.path.join(tmp_dir, "share") + absolute_path = install_test_helper.check_output( [resource_tool, "--print_resource_path", "drake/common/test/tmp_resource", @@ -42,19 +50,19 @@ def test_install_and_run(self): env=tool_env, ).strip() with open(absolute_path, 'r') as data: - self.assertEqual(data.read(), "tmp_resource") + self.assertEqual(data.read(), resource_data) # Remove environment variable. - absolute_path = subprocess.check_output( + absolute_path = install_test_helper.check_output( [resource_tool, "--print_resource_path", "drake/common/test/tmp_resource", "--add_resource_search_path", - "tmp/share", + os.path.join(tmp_dir, "share"), ], ).strip() with open(absolute_path, 'r') as data: - self.assertEqual(data.read(), "tmp_resource") + self.assertEqual(data.read(), resource_data) if __name__ == '__main__': diff --git a/examples/kuka_iiwa_arm/BUILD.bazel b/examples/kuka_iiwa_arm/BUILD.bazel index 684572086174..c7777dc83ca1 100644 --- a/examples/kuka_iiwa_arm/BUILD.bazel +++ b/examples/kuka_iiwa_arm/BUILD.bazel @@ -244,24 +244,14 @@ sh_test( tags = ["no_kcov"], ) -# `//:install` is run in this test to verify that once installed -# these examples still work. This test fails when bazel is run with -# `no_everything` because `libgurobi70.so` is not found [Issue #7283]. -drake_py_test( - name = "install_test", - size = "medium", - # Increase the timeout so that debug builds are successful. - timeout = "long", - srcs = ["test/install_test.py"], - data = ["//:install"], - main = "test/install_test.py", - tags = ["no_everything"], - deps = ["//tools/install:install_test_helper"], -) - # This examples needs to be install for external projects such as Spartan. install( name = "install", + install_tests = [ + ":test/iiwa_wsg_simulation_installed_test.py", + ":test/kuka_plan_runner_installed_test.py", + ":test/kuka_simulation_installed_test.py", + ], targets = [ ":iiwa_wsg_simulation", ":kuka_plan_runner", @@ -291,19 +281,10 @@ install( ], ) -# Run the installed flavor of the kuka simulation, to make sure it can locate -# its resources, etc. TODO(jwnimmer-tri) Refactor installed-test phrasing to -# be more compact, convenient, and efficient such as #7774 proposes. -drake_py_test( - name = "kuka_simulation_installed_test", - size = "small", - timeout = "long", - srcs = ["test/kuka_simulation_installed_test.py"], - data = ["//:install"], - main = "test/kuka_simulation_installed_test.py", - deps = [ - "//tools/install:install_test_helper", +add_lint_tests( + python_lint_extra_srcs = [ + ":test/iiwa_wsg_simulation_installed_test.py", + ":test/kuka_plan_runner_installed_test.py", + ":test/kuka_simulation_installed_test.py", ], ) - -add_lint_tests() diff --git a/examples/kuka_iiwa_arm/test/iiwa_wsg_simulation_installed_test.py b/examples/kuka_iiwa_arm/test/iiwa_wsg_simulation_installed_test.py new file mode 100755 index 000000000000..bfc13454b3f4 --- /dev/null +++ b/examples/kuka_iiwa_arm/test/iiwa_wsg_simulation_installed_test.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +import os +import shutil +import unittest +import sys +import install_test_helper + + +class TestIiwaWsgSimulation(unittest.TestCase): + def test_install(self): + # Get install directory. + install_dir = install_test_helper.get_install_dir() + # Make sure the simulation can run without error. We set cwd="/" to + # defeat the "search in parent folders" heuristic, so that the "use + # libdrake.so relative paths" must be successful. + simulation = os.path.join( + install_dir, + "share/drake/examples/kuka_iiwa_arm/iiwa_wsg_simulation") + self.assertTrue(os.path.exists(simulation), "Can't find " + simulation) + install_test_helper.check_call([simulation, "--simulation_sec=0.01"]) + + +if __name__ == '__main__': + unittest.main() diff --git a/examples/kuka_iiwa_arm/test/kuka_plan_runner_installed_test.py b/examples/kuka_iiwa_arm/test/kuka_plan_runner_installed_test.py new file mode 100755 index 000000000000..c301cad6c0ff --- /dev/null +++ b/examples/kuka_iiwa_arm/test/kuka_plan_runner_installed_test.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import unittest +import install_test_helper + + +class TestKukaSimulation(unittest.TestCase): + def test_install(self): + install_test_helper.run_and_kill( + ['share/drake/examples/kuka_iiwa_arm/kuka_plan_runner']) + + +if __name__ == '__main__': + unittest.main() diff --git a/examples/kuka_iiwa_arm/test/kuka_simulation_installed_test.py b/examples/kuka_iiwa_arm/test/kuka_simulation_installed_test.py old mode 100644 new mode 100755 index 4f047649808b..108f0cec897c --- a/examples/kuka_iiwa_arm/test/kuka_simulation_installed_test.py +++ b/examples/kuka_iiwa_arm/test/kuka_simulation_installed_test.py @@ -1,21 +1,14 @@ +#!/usr/bin/env python import os -import shutil -import subprocess import unittest -import sys import install_test_helper class TestKukaSimulation(unittest.TestCase): - def test(self): - # Install into the tmpdir that Bazel has created for us. - tmpdir = os.environ["TEST_TMPDIR"] - self.assertTrue(os.path.exists(tmpdir)) - install_dir = os.path.join(tmpdir, "install") - result = install_test_helper.install(install_dir, rmdir_cwd=False) - self.assertEqual(None, result) - + def test_install(self): + # Get install directory + install_dir = install_test_helper.get_install_dir() # Make sure the simulation can run without error. We set cwd="/" to # defeat the "search in parent folders" heuristic, so that the "use # libdrake.so relative paths" must be successful. @@ -23,7 +16,7 @@ def test(self): install_dir, "share/drake/examples/kuka_iiwa_arm/kuka_simulation") self.assertTrue(os.path.exists(simulation), "Can't find " + simulation) - subprocess.check_call([simulation, "--simulation_sec=0.01"], cwd="/") + install_test_helper.check_call([simulation, "--simulation_sec=0.01"]) if __name__ == '__main__': diff --git a/lcmtypes/BUILD.bazel b/lcmtypes/BUILD.bazel index 02c03ac52a9c..b218bcc2430d 100644 --- a/lcmtypes/BUILD.bazel +++ b/lcmtypes/BUILD.bazel @@ -368,6 +368,7 @@ install( install( name = "install", + install_tests = [":test/drake-lcm-spy_install_test.py"], targets = [ ":drake-lcm-spy-launcher", ":drake-lcm-spy", @@ -388,18 +389,6 @@ install( ) # === test/ === - -# `//:install` is run in this test to verify that once installed -# drake-lcm-spy still works. Cannot be run on its own, needs to be -# run as part of all the drake tests to have access to //:install -drake_py_test( - name = "drake-lcm-spy_install_test", - size = "large", - srcs = ["test/drake-lcm-spy_install_test.py"], - main = "test/drake-lcm-spy_install_test.py", - deps = ["//tools/install:install_test_helper"], -) - drake_py_test( name = "polynomial_matrix_test", srcs = ["test/polynomial_matrix_test.py"], @@ -408,4 +397,8 @@ drake_py_test( ], ) -add_lint_tests() +add_lint_tests( + python_lint_extra_srcs = [ + ":test/drake-lcm-spy_install_test.py", + ], +) diff --git a/lcmtypes/test/drake-lcm-spy_install_test.py b/lcmtypes/test/drake-lcm-spy_install_test.py old mode 100644 new mode 100755 index c2521906780b..af9bf213c093 --- a/lcmtypes/test/drake-lcm-spy_install_test.py +++ b/lcmtypes/test/drake-lcm-spy_install_test.py @@ -1,18 +1,18 @@ +#!/usr/bin/env python + import os import subprocess import unittest import install_test_helper -class TestCommonInstall(unittest.TestCase): - def testInstall(self): - tmp_folder = "tmp" - result = install_test_helper.install(tmp_folder, - ['bin', 'lib', 'share']) - self.assertEqual(None, result) - executable_folder = os.path.join(tmp_folder, "bin") +class TestLcmSpy(unittest.TestCase): + def test_install(self): + # Get install directory. + install_dir = install_test_helper.get_install_dir() + executable_folder = os.path.join(install_dir, "bin") try: - subprocess.check_output( + install_test_helper.check_output( [os.path.join(executable_folder, "drake-lcm-spy"), "--help"], stderr=subprocess.STDOUT ) diff --git a/tools/install/BUILD.bazel b/tools/install/BUILD.bazel index bbcab967d304..45c7de2047eb 100644 --- a/tools/install/BUILD.bazel +++ b/tools/install/BUILD.bazel @@ -3,6 +3,10 @@ package(default_visibility = ["//visibility:public"]) load("//tools/lint:lint.bzl", "add_lint_tests") +load( + "//tools/skylark:drake_py.bzl", + "drake_py_test", +) py_library( name = "cpsutils", @@ -20,7 +24,26 @@ py_library( ) exports_files( - ["install.py.in"], + [ + "install.py.in", + ], +) + +# Runs `install_test_helper` unit tests. +drake_py_test( + name = "install_test_helper_test", + size = "small", + deps = [":install_test_helper"], ) -add_lint_tests() +# Runs the install-testing framework with only minimal sanity checks; does not +# actually run any of the package-specific install_tests that are declared in +# install() rules elsewhere. +drake_py_test( + name = "install_tests", + size = "small", + # Increase the timeout so that debug builds are successful. + timeout = "long", + data = ["@drake//:install"], + deps = [":install_test_helper"], +) diff --git a/tools/install/install.bzl b/tools/install/install.bzl index 31c07a0bd29b..ed2c6fb0d75a 100644 --- a/tools/install/install.bzl +++ b/tools/install/install.bzl @@ -10,6 +10,8 @@ load( InstallInfo = provider() +InstalledTestInfo = provider() + #============================================================================== #BEGIN internal helpers @@ -88,8 +90,8 @@ def _guess_files(target, candidates, scope, attr_name): #------------------------------------------------------------------------------ def _install_action( - ctx, artifact, dests, strip_prefixes = [], rename = {}, warn_foreign = True -): + ctx, target, artifact, dests, strip_prefixes = [], + rename = {}, warn_foreign = True): """Compute install action for a single file. This takes a single file artifact and returns the appropriate install @@ -158,7 +160,7 @@ def _install_actions(ctx, file_labels, dests, strip_prefixes = [], continue actions.append( - _install_action(ctx, a, dests, strip_prefixes, + _install_action(ctx, f, a, dests, strip_prefixes, rename, warn_foreign) ) @@ -257,8 +259,9 @@ def _install_java_launcher_actions( actions = [] for jar in target[MainClassInfo].classpath: - jar_install = _install_action(ctx, jar, java_dest, java_strip_prefix, - rename, warn_foreign = False) + jar_install = _install_action(ctx, target, jar, java_dest, + java_strip_prefix, rename, + warn_foreign = False) # Adding double quotes around the generated scripts to avoid # white-space problems when running the generated shell script. This # string is used in a "for-loop" in the script. @@ -276,6 +279,27 @@ def _install_java_launcher_actions( return actions +#------------------------------------------------------------------------------ +# Compute install test actions. +def _install_test_actions( + ctx, + actions): + test_actions = [] + targets_dict = {} + # Create a dictionary of the targets to easily find if they are installed + # and where they are installed. + for a in actions: + if hasattr(a, "src"): + targets_dict[a.src] = a.dst + + # For files, we run the file from the build tree. + for test in ctx.attr.install_tests: + for f in test.files: + test_actions.append( + struct(src = f, cmd = f.path)) + + return test_actions + #------------------------------------------------------------------------------ # Generate install code for an install action. def _install_code(action): @@ -297,11 +321,14 @@ def _java_launcher_code(action): # targets, headers, or documentation files. def _install_impl(ctx): actions = [] + installed_tests = [] rename = dict(ctx.attr.rename) # Collect install actions from dependencies. for d in ctx.attr.deps: actions += d[InstallInfo].install_actions rename.update(d[InstallInfo].rename) + if InstalledTestInfo in d: + installed_tests += d[InstalledTestInfo].tests # Generate actions for data, docs and includes. actions += _install_actions(ctx, ctx.attr.docs, ctx.attr.doc_dest, @@ -335,6 +362,9 @@ def _install_impl(ctx): # Executable scripts copied from source directory. actions += _install_runtime_actions(ctx, t) + # Generate install test actions. + installed_tests += _install_test_actions(ctx, actions) + # Generate code for install actions. script_actions = [] installed_files = {} @@ -362,11 +392,29 @@ def _install_impl(ctx): output = ctx.outputs.executable, substitutions = {"<>": "\n ".join(script_actions)}) + script_tests = [] + # Generate list containing all commands to run to test. + for i in installed_tests: + script_tests.append(i.cmd) + + # Generate test installation script + if ctx.attr.install_tests_script: + ctx.template_action( + template = ctx.executable.install_test_script_template, + output = ctx.outputs.install_tests_script, + substitutions = + { + "cmds = []": "cmds = " + str(script_tests) + " # noqa" + } + ) + # Return actions. files = ctx.runfiles( - files = [a.src for a in actions if not hasattr(a, "main_class")]) + files = [a.src for a in actions if not hasattr(a, "main_class")] + + [i.src for i in installed_tests]) return [ InstallInfo(install_actions = actions, rename = rename), + InstalledTestInfo(tests = installed_tests), DefaultInfo(runfiles = files), ] @@ -401,6 +449,10 @@ install = rule( "py_dest": attr.string(default = "lib/python2.7/site-packages"), "py_strip_prefix": attr.string_list(), "rename": attr.string_dict(), + "install_tests": attr.label_list( + default = [], + allow_files = True, + ), "workspace": attr.string(), "allowed_externals": attr.label_list(allow_files = True), "install_script_template": attr.label( @@ -409,6 +461,13 @@ install = rule( cfg = "target", default = Label("//tools/install:install.py.in"), ), + "install_test_script_template": attr.label( + allow_files = True, + executable = True, + cfg = "target", + default = Label("//tools/install:test/install_tests.py"), + ), + "install_tests_script": attr.output(), }, executable = True, implementation = _install_impl, @@ -473,6 +532,15 @@ Note: filename = Java launcher file name ) + A Python launcher is created to test executables after installation. The + list of commands to run is given by the dictionary `test_commands`. The + key is the name of the executable. The list given for each key is the list + of arguments given to the executable at runtime. The keyword 'kill' is + a special argument that can be given to the Python script. It specifies + that the script will start the executable and kill it after a harcoded + period of time (2s). This is useful to test executables that do not + finish and exit normally. + Args: deps: List of other install rules that this rule should include. docs: List of documentation files to install. @@ -505,6 +573,12 @@ Args: py_strip_prefix: List of prefixes to remove from Python paths. rename: Mapping of install paths to alternate file names, used to rename files upon installation. + install_tests: List of scripts that are designed to test the install + tree. These scripts will not be installed. + install_tests_script: Name of the generated Python script that contains + the commands run to test the install tree. This only needs to be + specified for the main `install()` call, and the same name should be + passed to `install_test()` as `srcs`. workspace: Workspace name to use in default paths (overrides built-in guess). allowed_externals: List of external packages whose files may be installed. @@ -680,4 +754,30 @@ def install_cmake_config( visibility = visibility, ) +#------------------------------------------------------------------------------ +def install_test( + name, + src, + **kwargs): + """A wrapper to test installed drake executables. + + !!!Important: This command should be called only once, when the main + installation step occurs!!! + + This wrapper takes as `src` the file generated by the `install()` + rule. This list should contain only the file that is generated by the main + `install()` call since it will contain all the install tests declared in + the entire project. + """ + native.py_test( + name = name, + size = "small", + # Increase the timeout so that debug builds are successful. + timeout = "long", + srcs = [src], + main = src, + deps = ["//tools/install:install_test_helper"], + **kwargs + ) + #END macros diff --git a/tools/install/install_test_helper.py b/tools/install/install_test_helper.py index f7168eb00672..72829193d09c 100644 --- a/tools/install/install_test_helper.py +++ b/tools/install/install_test_helper.py @@ -1,46 +1,125 @@ +import errno import os -import shutil +import signal +import stat import subprocess +import sys +import time -def install(installation_folder="tmp", installed_subfolders=[], - rmdir_cwd=True): - """Install into a temporary directory. +def _make_read_only(path): + current = stat.S_IMODE(os.lstat(path).st_mode) + os.chmod(path, current & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)) + + +def install(): + """Install into a read-only temporary directory. Runs install script to install target in the specified temporary directory. The directory does not need to be removed as bazel tests are run in a scratch space. All build artifacts are removed from the scratch space, - leaving only the install directory. If `installed_subfolders` are not found - in installation directory, a string containing an error message is - returned. + leaving only the install directory. The install directory is made read-only + after installation process is completed. """ assert "TEST_TMPDIR" in os.environ, ( "This may only be run from within `bazel test`") - os.mkdir(installation_folder) + # Install into a tmpdir. + installation_folder = get_install_dir() + # The following will fail if `install()` is called multiple times, which it + # should not be anyway. + os.makedirs(installation_folder) + assert os.path.exists(installation_folder) # Install target and its dependencies in scratch space. - subprocess.check_call( - ["install", - os.path.abspath(installation_folder)] - ) - content_install_folder = os.listdir(installation_folder) - # Check that all expected folders are installed - for f in installed_subfolders: - if f not in content_install_folder: - return str(f) + " not found in " + str(content_install_folder) - # Skip the "remove Bazel build artifacts" when asked. - if not rmdir_cwd: - return None - # Remove Bazel build artifacts, and ensure that we only have install - # artifacts. - content_test_folder = os.listdir(os.getcwd()) - content_test_folder.remove(installation_folder) - for element in content_test_folder: - if os.path.isdir(element): - shutil.rmtree(element) - else: - os.remove(element) - content_install_folder = os.listdir(".") - if content_install_folder != [installation_folder]: - return ("Bazel build artifact not removed in test folder: " - + str(content_install_folder)) - return None + subprocess.check_call(["install", installation_folder]) + # Change permissions to remove write access. + for root, dirs, files in os.walk(installation_folder): + for d in dirs: + _make_read_only(os.path.join(root, d)) + for f in files: + _make_read_only(os.path.join(root, f)) + + +def get_install_dir(): + """Returns install directory. + + When running tests in the install tree, the current working directory + needs to be changed (e.g. to '/' to avoid finding artifacts from the + build tree when testing the installation). The caveat is that Python + scripts that run install tests may need to know the install directory. + `os.environ['TEST_TMPDIR']` is the absolute path to a private writable + directory. This function returns the path to this writable folder appended + with 'installation'. This allows to use this writable folder to create + additional files without modifying the install tree. + """ + return os.path.join(os.environ['TEST_TMPDIR'], 'installation') + + +def create_temporary_dir(name='tmp'): + """Creates temporary directory and returns its path. + + When running tests in the install tree, a temporary folder is created + by bazel and its path is accessible by getting the value of + `os.environ['TEST_TMPDIR']`. However, in the install_test_helper framework, + this folder is also used to install `drake`, so this function creates a + subdirectory nested inside `os.environ['TEST_TMPDIR']`. + """ + tmp_dir = os.path.join(os.environ['TEST_TMPDIR'], name) + os.mkdir(tmp_dir) + return tmp_dir + + +def get_python_executable(): + """Use appropriate Python executable + + Call python2 on MacOS to force using python brew install. Calling python + system would result in a crash since pydrake was built against brew python. + On other systems, it will just fall-back to the current Python executable. + """ + if sys.platform == "darwin": + return "python2" + else: + return sys.executable + + +def run_and_kill(cmd, timeout=2.0): + """Convenient function to start a command and kill it automatically. + + This function starts a given command and kills it after the given + `timeout`. This is useful if one needs to test a command that doesn't + terminate on its own. + + `cmd` is a list of a command and its arguments such as what is expected + by `subprocess.Popen` (See `subprocess` documentation). + """ + cmd[0] = os.path.join(get_install_dir(), cmd[0]) + proc = subprocess.Popen(cmd) + start = time.time() + while time.time() - start < timeout: + time.sleep(0.5) + ret = proc.poll() + assert ret is None + # time's up: kill the proc (and it's group): + proc.terminate() + assert proc.wait() == -signal.SIGTERM + + +def check_call(*args, **kwargs): + """Helper function for `subprocess.check_call()`. + + Install tests should use this function instead of + `subprocess.check_call()`. This function is a simple helper function that + calls `subprocess.check_call()` and updates the current working directory + to `/`. + """ + return subprocess.check_call(cwd='/', *args, **kwargs) + + +def check_output(*args, **kwargs): + """Helper function for `subprocess.check_output()`. + + Install tests should use this function instead of + `subprocess.check_output()`. This function is a simple helper function that + calls `subprocess.check_output()` and updates the current working directory + to `/`. + """ + return subprocess.check_output(cwd='/', *args, **kwargs) diff --git a/tools/install/test/install_test_helper_test.py b/tools/install/test/install_test_helper_test.py new file mode 100644 index 000000000000..ac6a4d6b7b90 --- /dev/null +++ b/tools/install/test/install_test_helper_test.py @@ -0,0 +1,53 @@ +import os +import unittest +import install_test_helper + + +class TestInstallTestHelperTest(unittest.TestCase): + def test_get_install_dir(self): + self.assertIn("TEST_TMPDIR", os.environ) + self.assertIn('installation', install_test_helper.get_install_dir()) + + def test_create_temporary_dir(self): + subdirectory_name = "tmp" + tmp_dir = install_test_helper.create_temporary_dir(subdirectory_name) + self.assertIn(subdirectory_name, tmp_dir) + self.assertTrue(os.path.isdir(tmp_dir)) + + def test_get_python_executable(self): + self.assertIn("python", install_test_helper.get_python_executable()) + + def test_run_and_kill(self): + python = install_test_helper.get_python_executable() + install_test_helper.run_and_kill([python, "-c", + "import time; time.sleep(5)"], 0.5) + + def test_check_call(self): + python = install_test_helper.get_python_executable() + install_test_helper.check_call([python, "--help"]) + + def test_check_output(self): + python = install_test_helper.get_python_executable() + output = install_test_helper.check_output([python, "--help"]) + self.assertIn('PYTHONPATH', output) + + def test_read_only(self): + tmp_dir = os.environ['TEST_TMPDIR'] + tmp_file = os.path.join(tmp_dir, "test_file") + with open(tmp_file, "w") as f: + f.write("") + install_test_helper._make_read_only(tmp_file) + try: + with open(tmp_file, "w") as f: + f.write("") + self.fail("File %s was writable!" % tmp_file) + except IOError as e: + # Test is suppose to raise an exception. + if e.errno == 13: # Permission denied + pass + else: + raise e + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/install/test/install_tests.py b/tools/install/test/install_tests.py new file mode 100644 index 000000000000..e9fad58c0286 --- /dev/null +++ b/tools/install/test/install_tests.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python + +# This file may have been configured automatically by `install.bzl`. + +import os +import unittest +import install_test_helper + + +cmds = [] + + +class TestCommonInstall(unittest.TestCase): + def testDrakeInstall(self): + # Install into bazel read-only temporary directory. The temporary + # directory does not need to be removed as bazel tests are run in a + # scratch space. + install_test_helper.install() + installation_folder = install_test_helper.get_install_dir() + self.assertTrue(os.path.isdir(installation_folder)) + # Execute the install actions. + for cmd in cmds: + install_test_helper.check_call(os.path.join(os.getcwd(), cmd)) + content = os.listdir(installation_folder) + self.assertSetEqual(set(['bin', 'include', 'lib', 'plugins', 'share']), + set(content)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/workspace/lcm/BUILD.bazel b/tools/workspace/lcm/BUILD.bazel index aa5a9460ec93..c190261e8a48 100644 --- a/tools/workspace/lcm/BUILD.bazel +++ b/tools/workspace/lcm/BUILD.bazel @@ -1,25 +1,13 @@ # -*- python -*- load("//tools/lint:lint.bzl", "add_lint_tests") -load( - "@drake//tools/skylark:drake_py.bzl", - "drake_py_test", -) exports_files( - ["package-create-cps.py"], + [ + "package-create-cps.py", + "test/lcm-gen_install_test.py", + ], visibility = ["@lcm//:__pkg__"], ) -# `//:install` is run in this test to verify that once installed -# lcm-gen still works. Cannot be run on its own, needs to be -# run as part of all the drake tests to have access to //:install -drake_py_test( - name = "install_test", - size = "large", - srcs = ["test/install_test.py"], - main = "test/install_test.py", - deps = ["//tools/install:install_test_helper"], -) - -add_lint_tests() +add_lint_tests(python_lint_extra_srcs = ["test/lcm-gen_install_test.py"]) diff --git a/tools/workspace/lcm/package.BUILD.bazel b/tools/workspace/lcm/package.BUILD.bazel index ee4111d4a702..ec9d60773e70 100644 --- a/tools/workspace/lcm/package.BUILD.bazel +++ b/tools/workspace/lcm/package.BUILD.bazel @@ -294,6 +294,9 @@ install( install( name = "install", + install_tests = [ + "@drake//tools/workspace/lcm:test/lcm-gen_install_test.py", + ], targets = [ ":lcm-gen", ":lcm-java", @@ -325,4 +328,4 @@ install( ], ) -python_lint() +python_lint(extra_srcs = [":test/lcm-gen_install_test.py"]) diff --git a/tools/workspace/lcm/test/install_test.py b/tools/workspace/lcm/test/install_test.py deleted file mode 100644 index 9a9e6d2deaba..000000000000 --- a/tools/workspace/lcm/test/install_test.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import absolute_import, division, print_function - -import os -import subprocess -import unittest -import install_test_helper - - -class TestCommonInstall(unittest.TestCase): - def testInstall(self): - tmp_folder = "tmp" - result = install_test_helper.install(tmp_folder, - ['bin', 'lib', 'share']) - self.assertEqual(None, result) - executable_folder = os.path.join(tmp_folder, "bin") - subprocess.check_call([os.path.join(executable_folder, "lcm-gen")]) - - -if __name__ == '__main__': - unittest.main() diff --git a/tools/workspace/lcm/test/lcm-gen_install_test.py b/tools/workspace/lcm/test/lcm-gen_install_test.py new file mode 100755 index 000000000000..85e58ccea894 --- /dev/null +++ b/tools/workspace/lcm/test/lcm-gen_install_test.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python + +import os +import unittest +import install_test_helper + + +class TestLcmGen(unittest.TestCase): + def test_install(self): + # Get install directory. + install_dir = install_test_helper.get_install_dir() + executable_folder = os.path.join(install_dir, "bin") + install_test_helper.check_call( + [os.path.join(executable_folder, "lcm-gen"), "--help"], + ) + + +if __name__ == '__main__': + unittest.main()