Build and run unit tests through GN

This change adds a GN template for defining unit test executables.
The template, called pw_test, defines the executable and outputs a JSON
metadata file for the test.

A new build argument is added. This argument determines whether unit
test run targets are supported by the current build target. If this is
set, the pw_test template additionally creates a run target for its test
executable which invokes the executable through a script.

A basic test runner script is added to the pw_unit_test module. This
script currently only runs a single test executable directly.

The unit tests in the pw_preprocessor module are updated to use the
pw_test template.

Change-Id: I3cbde9c19440276dbab80dd2bab5fec87abe6d7e
diff --git a/BUILD.gn b/BUILD.gn
index 8d69e9b..d696ae4 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -20,3 +20,10 @@
     "$dir_pw_unit_test",
   ]
 }
+
+# Targets for all module unit test groups.
+group("pw_module_tests") {
+  deps = [
+    "$dir_pw_preprocessor:unit_tests",
+  ]
+}
diff --git a/pw_preprocessor/BUILD.gn b/pw_preprocessor/BUILD.gn
index ccc89ad..230d65b 100644
--- a/pw_preprocessor/BUILD.gn
+++ b/pw_preprocessor/BUILD.gn
@@ -12,6 +12,8 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 
+import("$dir_pw_unit_test/test.gni")
+
 config("default_config") {
   include_dirs = [ "public" ]
 }
@@ -31,7 +33,8 @@
   sources = public
 }
 
-group("pw_preprocessor_tests") {
+# All pw_preprocessor test binaries.
+group("unit_tests") {
   deps = [
     ":boolean_test",
     ":concat_test",
@@ -40,49 +43,24 @@
   ]
 }
 
-group("pw_preprocessor_tests_linux") {
-  deps = [
-    ":pw_preprocessor_tests($dir_pw_toolchain:x86_linux_o2)",
-  ]
+template("preprocessor_test") {
+  not_needed([ "invoker" ])
+  pw_test(target_name) {
+    deps = [
+      ":pw_preprocessor",
+      "$dir_pw_unit_test:main",
+    ]
+    sources = [
+      "$target_name.cc",
+    ]
+  }
 }
 
-# TODO(frolv): Change these to special unit test executables.
-executable("boolean_test") {
-  deps = [
-    ":pw_preprocessor",
-    "$dir_pw_unit_test:main",
-  ]
-  sources = [
-    "boolean_test.cc",
-  ]
+preprocessor_test("boolean_test") {
 }
-
-executable("concat_test") {
-  deps = [
-    ":pw_preprocessor",
-    "$dir_pw_unit_test:main",
-  ]
-  sources = [
-    "concat_test.cc",
-  ]
+preprocessor_test("concat_test") {
 }
-
-executable("macro_arg_count_test") {
-  deps = [
-    ":pw_preprocessor",
-    "$dir_pw_unit_test:main",
-  ]
-  sources = [
-    "macro_arg_count_test.cc",
-  ]
+preprocessor_test("macro_arg_count_test") {
 }
-
-executable("util_test") {
-  deps = [
-    ":pw_preprocessor",
-    "$dir_pw_unit_test:main",
-  ]
-  sources = [
-    "util_test.cc",
-  ]
+preprocessor_test("util_test") {
 }
diff --git a/pw_unit_test/py/test_runner.py b/pw_unit_test/py/test_runner.py
new file mode 100644
index 0000000..f7f4e46
--- /dev/null
+++ b/pw_unit_test/py/test_runner.py
@@ -0,0 +1,90 @@
+# Copyright 2019 The Pigweed Authors
+#
+# 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
+#
+#     https://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.
+
+"""Script which runs Pigweed unit tests built using GN.
+
+Currently, only a single test can be run at a time. The build path and GN target
+name of the test are given to the script.
+"""
+
+import argparse
+import pathlib
+import os
+import subprocess
+import sys
+
+
+def parse_args() -> argparse.Namespace:
+    """Parses command-line arguments."""
+
+    parser = argparse.ArgumentParser('Run Pigweed unit tests')
+    parser.add_argument('--touch', type=str,
+                        help='File to touch after test run')
+    parser.add_argument('test', type=str, help='Path to unit test binary')
+    return parser.parse_args()
+
+
+# TODO(frolv): This should be extracted into a script-running script which
+# performs path resolution before calling another script.
+def find_binary(target: str) -> str:
+    """Tries to find a binary for a gn build target.
+
+    Args:
+        target: Relative path to the target's output directory and target name,
+            separated by a colon.
+
+    Returns:
+        Full path to the target's binary.
+
+    Raises:
+        RuntimeError: No binary found for target.
+    """
+
+    target_path, target_name = target.split(':')
+
+    extensions = ['', '.elf']
+    for extension in extensions:
+        potential_filename = f'{target_path}/{target_name}{extension}'
+        if os.path.isfile(potential_filename):
+            return potential_filename
+
+    raise FileNotFoundError(
+        f'could not find output binary for build target {target}')
+
+
+def main() -> int:
+    """Runs some unit tests."""
+
+    args = parse_args()
+
+    try:
+        test_binary = find_binary(args.test)
+    except FileNotFoundError as err:
+        print(f'{sys.argv[0]}: {err}', file=sys.stderr)
+        return 1
+
+    exit_status = subprocess.call([test_binary])
+
+    # GN expects "action" targets to output a file, and uses that to determine
+    # whether the target should be run again. Touching an empty file allows GN
+    # to only run unit tests which have been affected by code changes since the
+    # previous run, taking advantage of its dependency resolution.
+    if args.touch is not None:
+        pathlib.Path(args.touch).touch()
+
+    return exit_status
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/pw_unit_test/test.gni b/pw_unit_test/test.gni
new file mode 100644
index 0000000..bb1577e
--- /dev/null
+++ b/pw_unit_test/test.gni
@@ -0,0 +1,108 @@
+# Copyright 2019 The Pigweed Authors
+#
+# 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
+#
+#     https://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("$dir_pw_build/pw_executable.gni")
+
+declare_args() {
+  # Whether GN unit test runner targets should be created.
+  #
+  # If set to true, the pw_test_run() template will create an action that
+  # invokes the test runner script on a test executable.
+  # If false, the pw_test_run() template becomes a no-op.
+  #
+  # This should be enabled for targets which support parallelized running
+  # of unit tests, such as desktops with multiple cores.
+  pw_unit_test_create_run_targets = false
+}
+
+# Creates an executable target for a unit test.
+# Additionally, outputs a file containing unit test metadata in JSON format for
+# the test runner script.
+#
+# This template accepts all of the regular "executable" target args.
+template("pw_test") {
+  # This is required in order to reference the pw_test template's target name
+  # within the test_metadata of the metadata group below. The group() definition
+  # creates a new scope where the "target_name" variable is set to its target,
+  # shadowing the one in this scope.
+  _target_name = target_name
+
+  # Metadata for the test runner script. This is a dummy target which doesn't
+  # require or produce anything; it simply exists to define the unit test
+  # metadata.
+  _metadata_group_target = _target_name + "_pw_metadata_group"
+  group(_metadata_group_target) {
+    metadata = {
+      test_metadata = [
+        {
+          test_name = _target_name
+        },
+      ]
+    }
+  }
+
+  # Output file for the unit test metadata. Reads metadata from the dummy group
+  # target and outputs to a JSON file.
+  _metadata_file_target = _target_name + "_pw_test_metadata"
+  generated_file(_metadata_file_target) {
+    outputs = [
+      "$target_out_dir/$_target_name.meta.json",
+    ]
+    data_keys = [ "test_metadata" ]
+    output_conversion = "json"
+
+    deps = [
+      ":$_metadata_group_target",
+    ]
+  }
+
+  # Actual executable file for the unit test. Depends on the metadata output
+  # file in order to generate it as well.
+  pw_executable(_target_name) {
+    forward_variables_from(invoker, "*")
+
+    if (!defined(deps)) {
+      deps = []
+    }
+    deps += [ ":$_metadata_file_target" ]
+  }
+
+  if (pw_unit_test_create_run_targets) {
+    # When the run targets arg is set, create an action which runs the unit test
+    # executable using the test runner script.
+    _run_action_name = _target_name + "_run"
+
+    # Resolve the GN path of a dependency to a filesystem path relative to the
+    # build directory. Keep the target name.
+    _binary_path = rebase_path(target_out_dir, root_build_dir) + ":$_target_name"
+
+    # The runner script touches this file to indicate that the test has run.
+    _stamp_file = "$target_gen_dir/${_target_name}.test_completion.stamp"
+
+    action(_run_action_name) {
+      deps = [
+        ":$_target_name",
+      ]
+      script = "$dir_pw_unit_test/py/test_runner.py"
+      args = [
+        "--touch",
+        rebase_path(_stamp_file, root_build_dir),
+        _binary_path,
+      ]
+      outputs = [
+        _stamp_file,
+      ]
+    }
+  }
+}