Add a repo hook to validate TEST_MAPPING file.
am: 45c9c682dc

Change-Id: Ice9f69a83f2b08ae9113032bb1aaaff24c9111ae
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg
index e82319c..96b1dfc 100644
--- a/PREUPLOAD.cfg
+++ b/PREUPLOAD.cfg
@@ -3,6 +3,7 @@
 config_unittest = ./rh/config_unittest.py
 hooks_unittest  = ./rh/hooks_unittest.py
 shell_unittest  = ./rh/shell_unittest.py
+android_test_mapping_format_unittest = ./tools/android_test_mapping_format_unittest.py
 
 [Builtin Hooks]
 commit_msg_bug_field = true
diff --git a/README.md b/README.md
index a320e99..faab1cf 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,8 @@
 * `jsonlint`: Verify JSON code is sane.
 * `pylint`: Run Python code through `pylint`.
 * `xmllint`: Run XML code through `xmllint`.
+* `android_test_mapping_format`: Validate TEST_MAPPING files in Android source
+  code. Refer to go/test-mapping for more details.
 
 Note: Builtin hooks tend to match specific filenames (e.g. `.json`).  If no
 files match in a specific commit, then the hook will be skipped for that commit.
@@ -200,6 +202,8 @@
 * `google-java-format`: used for the `google_java_format` builtin hook.
 * `google-java-format-diff`: used for the `google_java_format` builtin hook.
 * `pylint`: used for the `pylint` builtin hook.
+* `android-test-mapping-format`: used for the `android_test_mapping_format`
+  builtin hook.
 
 See [Placeholders](#Placeholders) for variables you can expand automatically.
 
diff --git a/rh/hooks.py b/rh/hooks.py
index 1898364..bf70643 100644
--- a/rh/hooks.py
+++ b/rh/hooks.py
@@ -596,9 +596,24 @@
     return _check_cmd('xmllint', project, commit, cmd)
 
 
+def check_android_test_mapping(project, commit, _desc, diff, options=None):
+    """Verify Android TEST_MAPPING files are valid."""
+    if options.args():
+        raise ValueError('Android TEST_MAPPING check takes no options')
+    filtered = _filter_diff(diff, [r'(^|.*/)TEST_MAPPING$'])
+    if not filtered:
+        return
+
+    testmapping_format = options.tool_path('android-test-mapping-format')
+    cmd = [testmapping_format] + options.args(
+        (project.dir, '${PREUPLOAD_FILES}',), filtered)
+    return _check_cmd('android-test-mapping-format', project, commit, cmd)
+
+
 # Hooks that projects can opt into.
 # Note: Make sure to keep the top level README.md up to date when adding more!
 BUILTIN_HOOKS = {
+    'android_test_mapping_format': check_android_test_mapping,
     'checkpatch': check_checkpatch,
     'clang_format': check_clang_format,
     'commit_msg_bug_field': check_commit_msg_bug_field,
@@ -616,6 +631,8 @@
 # Additional tools that the hooks can call with their default values.
 # Note: Make sure to keep the top level README.md up to date when adding more!
 TOOL_PATHS = {
+    'android-test-mapping-format':
+        os.path.join(TOOLS_DIR, 'android_test_mapping_format.py'),
     'clang-format': 'clang-format',
     'cpplint': os.path.join(TOOLS_DIR, 'cpplint.py'),
     'git-clang-format': 'git-clang-format',
diff --git a/rh/hooks_unittest.py b/rh/hooks_unittest.py
index 38b8df2..b7b5c86 100755
--- a/rh/hooks_unittest.py
+++ b/rh/hooks_unittest.py
@@ -502,6 +502,20 @@
         self._test_file_filter(mock_check, rh.hooks.check_xmllint,
                                ('foo.xml',))
 
+    def test_android_test_mapping_format(self, mock_check, _mock_run):
+        """Verify the android_test_mapping_format builtin hook."""
+        # First call should do nothing as there are no files to check.
+        ret = rh.hooks.check_android_test_mapping(
+            self.project, 'commit', 'desc', (), options=self.options)
+        self.assertIsNone(ret)
+        self.assertFalse(mock_check.called)
+
+        # Second call will have some results.
+        diff = [rh.git.RawDiffEntry(file='TEST_MAPPING')]
+        ret = rh.hooks.check_android_test_mapping(
+            self.project, 'commit', 'desc', diff, options=self.options)
+        self.assertIsNotNone(ret)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tools/android_test_mapping_format.py b/tools/android_test_mapping_format.py
new file mode 100755
index 0000000..49725b3
--- /dev/null
+++ b/tools/android_test_mapping_format.py
@@ -0,0 +1,130 @@
+#!/usr/bin/python
+# -*- coding:utf-8 -*-
+# Copyright 2018 The Android Open Source Project
+#
+# 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.
+
+"""Validate TEST_MAPPING files in Android source code.
+
+The goal of this script is to validate the format of TEST_MAPPING files:
+1. It must be a valid json file.
+2. Each test group must have a list of test that containing name and options.
+3. Each import must have only one key `path` and one value for the path to
+   import TEST_MAPPING files.
+"""
+
+import argparse
+import json
+import os
+import sys
+
+IMPORTS = 'imports'
+NAME = 'name'
+OPTIONS = 'options'
+PATH = 'path'
+
+
+class Error(Exception):
+    """Base exception for all custom exceptions in this module."""
+
+
+class InvalidTestMappingError(Error):
+    """Exception to raise when detecting an invalid TEST_MAPPING file."""
+
+
+def _validate_import(entry, test_mapping_file):
+    """Validate an import setting.
+
+    Args:
+        entry: A dictionary of an import setting.
+        test_mapping_file: Path to the TEST_MAPPING file to be validated.
+
+    Raises:
+        InvalidTestMappingError: if the import setting is invalid.
+    """
+    if len(entry) != 1:
+        raise InvalidTestMappingError(
+            'Invalid import config in test mapping file %s. each import can '
+            'only have one `path` setting. Failed entry: %s' %
+            (test_mapping_file, entry))
+    if entry.keys()[0] != PATH:
+        raise InvalidTestMappingError(
+            'Invalid import config in test mapping file %s. import can only '
+            'have one `path` setting. Failed entry: %s' %
+            (test_mapping_file, entry))
+
+
+def _validate_test(test, test_mapping_file):
+    """Validate a test declaration.
+
+    Args:
+        entry: A dictionary of a test declaration.
+        test_mapping_file: Path to the TEST_MAPPING file to be validated.
+
+    Raises:
+        InvalidTestMappingError: if the a test declaration is invalid.
+    """
+    if NAME not in test:
+        raise InvalidTestMappingError(
+            'Invalid test config in test mapping file %s. test config must '
+            'a `name` setting. Failed test config: %s' %
+            (test_mapping_file, test))
+    for option in test.get(OPTIONS, []):
+        if len(option) != 1:
+            raise InvalidTestMappingError(
+                'Invalid option setting in test mapping file %s. each option '
+                'setting can only have one key-val setting. Failed entry: %s' %
+                (test_mapping_file, option))
+
+
+def _load_file(test_mapping_file):
+    """Load a TEST_MAPPING file as a json file."""
+    try:
+        with open(test_mapping_file) as file_obj:
+            return json.load(file_obj)
+    except ValueError as e:
+        # The file is not a valid JSON file.
+        raise InvalidTestMappingError(
+            'Failed to parse JSON file %s, error: %s' % (test_mapping_file, e))
+
+
+def process_file(test_mapping_file):
+    """Validate a TEST_MAPPING file."""
+    test_mapping = _load_file(test_mapping_file)
+    # Validate imports.
+    for import_entry in test_mapping.get(IMPORTS, []):
+        _validate_import(import_entry, test_mapping_file)
+    # Validate tests.
+    all_tests = [test for group, tests in test_mapping.items()
+                 if group != IMPORTS for test in tests]
+    for test in all_tests:
+        _validate_test(test, test_mapping_file)
+
+
+def get_parser():
+    """Return a command line parser."""
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('project_dir')
+    parser.add_argument('files', nargs='+')
+    return parser
+
+
+def main(argv):
+    parser = get_parser()
+    opts = parser.parse_args(argv)
+    for filename in opts.files:
+        process_file(os.path.join(opts.project_dir, filename))
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv[1:]))
diff --git a/tools/android_test_mapping_format_unittest.py b/tools/android_test_mapping_format_unittest.py
new file mode 100755
index 0000000..d8a3167
--- /dev/null
+++ b/tools/android_test_mapping_format_unittest.py
@@ -0,0 +1,160 @@
+#!/usr/bin/python
+# -*- coding:utf-8 -*-
+# Copyright 2018 The Android Open Source Project
+#
+# 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 shutil
+import tempfile
+import unittest
+
+import android_test_mapping_format
+
+
+VALID_TEST_MAPPING = """
+{
+  "presubmit": [
+    {
+      "name": "CtsWindowManagerDeviceTestCases",
+      "options": [
+        {
+          "include-annotation": "android.platform.test.annotations.Presubmit"
+        }
+      ]
+    }
+  ],
+  "postsubmit": [
+    {
+      "name": "CtsWindowManagerDeviceTestCases"
+    }
+  ],
+  "imports": [
+    {
+      "path": "frameworks/base/services/core/java/com/android/server/am"
+    },
+    {
+      "path": "frameworks/base/services/core/java/com/android/server/wm"
+    }
+  ]
+}
+"""
+
+BAD_JSON = """
+{wrong format}
+"""
+
+BAD_TEST_WRONG_KEY = """
+{
+  "presubmit": [
+    {
+      "bad_name": "CtsWindowManagerDeviceTestCases",
+    }
+  ],
+}
+"""
+
+BAD_TEST_WRONG_OPTION = """
+{
+  "presubmit": [
+    {
+      "name": "CtsWindowManagerDeviceTestCases",
+      "options": [
+        {
+          "include-annotation": "android.platform.test.annotations.Presubmit",
+          "bad_option": "some_name"
+        }
+      ]
+    }
+  ],
+}
+"""
+
+BAD_IMPORT_WRONG_KEY = """
+{
+  "imports": [
+    {
+      "name": "frameworks/base/services/core/java/com/android/server/am"
+    }
+  ]
+}
+"""
+
+BAD_IMPORT_WRONG_IMPORT_VALUE = """
+{
+  "imports": [
+    {
+      "path": "frameworks/base/services/core/java/com/android/server/am",
+      "option": "something"
+    }
+  ]
+}
+"""
+
+
+class AndroidTestMappingFormatTests(unittest.TestCase):
+    """Unittest for android_test_mapping_format module."""
+
+    def setUp(self):
+        self.tempdir = tempfile.mkdtemp()
+        self.test_mapping_file = os.path.join(self.tempdir, 'TEST_MAPPING')
+
+    def tearDown(self):
+        shutil.rmtree(self.tempdir)
+
+    def test_valid_test_mapping(self):
+        """Verify that the check doesn't raise any error for valid test mapping.
+        """
+        with open(self.test_mapping_file, 'w') as f:
+            f.write(VALID_TEST_MAPPING)
+        android_test_mapping_format.process_file(self.test_mapping_file)
+
+    def test_invalid_test_mapping_wrong_test_key(self):
+        """Verify that test config using wrong key can be detected."""
+        with open(self.test_mapping_file, 'w') as f:
+            f.write(BAD_TEST_WRONG_KEY)
+        self.assertRaises(
+            android_test_mapping_format.InvalidTestMappingError,
+            android_test_mapping_format.process_file,
+            self.test_mapping_file)
+
+    def test_invalid_test_mapping_wrong_test_option(self):
+        """Verify that test config using wrong option can be detected."""
+        with open(self.test_mapping_file, 'w') as f:
+            f.write(BAD_TEST_WRONG_OPTION)
+        self.assertRaises(
+            android_test_mapping_format.InvalidTestMappingError,
+            android_test_mapping_format.process_file,
+            self.test_mapping_file)
+
+    def test_invalid_test_mapping_wrong_import_key(self):
+        """Verify that import setting using wrong key can be detected."""
+        with open(self.test_mapping_file, 'w') as f:
+            f.write(BAD_IMPORT_WRONG_KEY)
+        self.assertRaises(
+            android_test_mapping_format.InvalidTestMappingError,
+            android_test_mapping_format.process_file,
+            self.test_mapping_file)
+
+    def test_invalid_test_mapping_wrong_import_value(self):
+        """Verify that import setting using wrong value can be detected."""
+        with open(self.test_mapping_file, 'w') as f:
+            f.write(BAD_IMPORT_WRONG_IMPORT_VALUE)
+        self.assertRaises(
+            android_test_mapping_format.InvalidTestMappingError,
+            android_test_mapping_format.process_file,
+            self.test_mapping_file)
+
+
+if __name__ == '__main__':
+    unittest.main()