diff --git a/.github/workflows/unit_test.yaml b/.github/workflows/unit_test.yaml index dd1fb12b..70e8b2b3 100644 --- a/.github/workflows/unit_test.yaml +++ b/.github/workflows/unit_test.yaml @@ -71,6 +71,7 @@ jobs: - name: Run tests run: | cd test + bazel test //... python3 -m unittest discover unit -vvv rhel9_test: @@ -108,4 +109,5 @@ jobs: - name: Run tests run: | cd test + runuser -u test -- bazel test //... runuser -u test -- python3 -m unittest discover unit -vvv diff --git a/test/unit/BUILD b/test/unit/BUILD new file mode 100644 index 00000000..35047442 --- /dev/null +++ b/test/unit/BUILD @@ -0,0 +1,15 @@ +# Copyright 2023 Ericsson AB +# +# 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. + +exports_files(["grep_check.py"]) diff --git a/test/unit/grep_check.py b/test/unit/grep_check.py new file mode 100644 index 00000000..9787c79b --- /dev/null +++ b/test/unit/grep_check.py @@ -0,0 +1,207 @@ +# Copyright 2023 Ericsson AB +# +# 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. + +""" +Validates wether a list of patterns is found in a file + +This test reads a file and asserts that all provided patterns +are present within its contents. + +Intended to be used as the main of a py_test Bazel target. +""" + +import argparse +import glob +from itertools import chain +import re +import sys +from typing import Callable + + +def parse_args() -> argparse.Namespace: + """ + Parse command-line arguments. + Returns: + Parsed arguments containing the file path and list of patterns. + """ + parser = argparse.ArgumentParser( + description=( + "Assert that all given patterns exist in the provided file." + ) + ) + parser.add_argument( + "--files", + nargs="+", + required=True, + help="Path or glob pattern to the file(s) to search within.", + ) + parser.add_argument( + "--patterns", + nargs="+", + required=False, + help="One or more patterns to assert are present in the file(s).", + ) + parser.add_argument( + "--negative_patterns", + nargs="+", + required=False, + help="One or more patterns to assert are not present in the file(s).", + ) + parser.add_argument( + "--regex_patterns", + nargs="+", + required=False, + help="One or more patterns to assert are present in the file(s).", + ) + parser.add_argument( + "--negative_regex_patterns", + nargs="+", + required=False, + help="One or more patterns to assert are not present in the file(s).", + ) + parser.add_argument( + "--any", + required=False, + action="store_true", + help="If provided, the program will succeed if at least one file " + "contains the patterns", + ) + return parser.parse_args() + + +def check_args(args): + """Checks wether the arguments are correct, aborts if not""" + if ( + not args.patterns + and not args.negative_patterns + and not args.regex_patterns + and not args.negative_regex_patterns + ): + print(" [ERROR] Must define at least one pattern or negative pattern.") + sys.exit(1) + + +def exact_match(pattern: str, content: str) -> bool: + """Default search: checks if pattern is exactly in content.""" + return pattern in content + + +def check_patterns( + content: str, + patterns: list[str], + search: Callable[[str, str], bool] = exact_match, + negative: bool = False, +) -> tuple[bool, set[str], set[str]]: + """ + Checks wether a string contains every pattern in a list. + + Args: + content: Text to search in. + patterns: List of search patterns. + search: Function with signature func(pattern, content) -> bool. + Defaults to `pattern in content`. + negative: Boolean, wether to check patterns as positive or negative. + Returns: + bool - Wether all patterns are correctly (not) found. + set[str] - Set of patterns that are correctly (not) found. + set[str] - Set of patterns that are incorrectly (not) found. + """ + all_passed = True + found_patterns = set() + missing_pattern = set() + for pattern in patterns: + if bool(search(pattern, content)) == negative: + missing_pattern.add(pattern) + all_passed = False + else: + found_patterns.add(pattern) + return all_passed, found_patterns, missing_pattern + + +def check_file(content: str, args) -> tuple[bool, set[str], set[str]]: + """ + Checks if file contains all regexes. + Returns boolean value, and set of patterns correctly identified. + """ + all_passed = True + found_patterns = set() + missing_patterns = set() + + groups = [ + (args.patterns, exact_match, False), + (args.negative_patterns, exact_match, True), + (args.regex_patterns, re.search, False), + (args.negative_regex_patterns, re.search, True), + ] + + for patterns, search, negative in groups: + if patterns: + group_pass, found, missing = check_patterns( + content, patterns, search, negative + ) + all_passed = all_passed and group_pass + found_patterns.update(found) + missing_patterns.update(missing) + + return all_passed, found_patterns, missing_patterns + + +def main() -> None: + """Entry point for the pattern-matching test.""" + args = parse_args() + check_args(args) + + all_passed = True + found_patterns = set() + missing_patterns = set() + + file_paths = [] + for file_pattern in args.files: + matched_files = glob.glob(file_pattern, recursive=True) + if not matched_files: + print(f" [WARN] No files matched pattern/path: '{file_pattern}'") + file_paths.extend(matched_files) + + for file in file_paths: + with open(file, "r", encoding="utf-8") as f: + content = f.read() + all_found_in_file, patterns_in_file, missing_patterns_in_file = ( + check_file(content, args) + ) + all_passed = all_passed and all_found_in_file + found_patterns.update(patterns_in_file) + for pattern in missing_patterns_in_file: + missing_patterns.add((file, pattern)) + + if args.any: + all_passed = True + for pattern in chain( + args.patterns or [], + args.negative_patterns or [], + args.regex_patterns or [], + args.negative_regex_patterns or [], + ): + if pattern not in found_patterns: + all_passed = False + break + + if not all_passed: + for file, pattern in missing_patterns: + print(f"Missing pattern {pattern} in file {file}") + print("\nOne or more patterns missing. Test FAILED.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/test/unit/legacy/BUILD b/test/unit/legacy/BUILD index 3e263675..cef04529 100644 --- a/test/unit/legacy/BUILD +++ b/test/unit/legacy/BUILD @@ -1,3 +1,10 @@ +# cc_binary for simple C++ tests +load( + "@rules_cc//cc:defs.bzl", + "cc_binary", + "cc_library", +) + # clang-tidy and clang -analyze rules load( "//src:clang.bzl", @@ -25,12 +32,9 @@ load( "//src:compile_commands.bzl", "compile_commands", ) - -# cc_binary for simple C++ tests load( - "@rules_cc//cc:defs.bzl", - "cc_binary", - "cc_library", + "//test/unit:unit_test.bzl", + "unit_test", ) # Test for strip_include_prefix @@ -80,6 +84,18 @@ compile_commands( ], ) +unit_test( + name = "compile_commands_pass_test", + data = [ + ":compile_commands_pass/compile_commands.json", + ], + files = "$(location :compile_commands_pass/compile_commands.json)", + patterns = [ + r"pass\.cc", + r"\/gcc", + ], +) + # CodeChecker configuration options specification # based on Bazel configuration approach codechecker_config( @@ -146,6 +162,20 @@ codechecker_test( ], ) +unit_test( + name = "codechecker_fail_test", + data = [ + ":codechecker_fail/codechecker.log", + ], + files = "$(location :codechecker_fail/codechecker.log)", + regex_patterns = [ + r"clang-diagnostic-unused-variable\\s+\\|\\s+MEDIUM\\s+\\|\\s+1", + r"core.NullDereference\\s+\\|\\s+HIGH\\s+\\|\\s+1", + r"deadcode.DeadStores\\s+\\|\\s+LOW\\s+\\|\\s+1", + r"lib.cc\\s+\\|\\s+3", + ], +) + # This codechecker_test CTU example supposed to fail showing findings report # Note "manual" tag (means should not be run with other tests) codechecker_test( diff --git a/test/unit/legacy/test_legacy.py b/test/unit/legacy/test_legacy.py index 5aae4312..7b4f0566 100644 --- a/test/unit/legacy/test_legacy.py +++ b/test/unit/legacy/test_legacy.py @@ -98,18 +98,6 @@ def test_bazel_build_pass(self): """Test: bazel build :test_pass""" self.check_command("bazel build :test_pass") - def test_bazel_test_fail(self): - """Test: bazel test :codechecker_fail""" - self.check_command("bazel test :codechecker_fail", exit_code=3) - logfile = os.path.join( - self.BAZEL_BIN_DIR, "codechecker_fail", "codechecker.log") - self.grep_file( - logfile, - r"clang-diagnostic-unused-variable\s+\|\s+MEDIUM\s+\|\s+1") - self.grep_file(logfile, r"core.NullDereference\s+\|\s+HIGH\s+\|\s+1") - self.grep_file(logfile, r"deadcode.DeadStores\s+\|\s+LOW\s+\|\s+1") - self.grep_file(logfile, r"lib.cc\s+\|\s+3") - def test_bazel_test_ctu(self): """Test: bazel test :codechecker_ctu""" self.check_command("bazel test :codechecker_ctu", exit_code=3) @@ -121,16 +109,6 @@ def test_bazel_build_fail(self): """Test: bazel build :test_fail""" self.check_command("bazel build :test_fail", exit_code=0) - def test_bazel_compile_commands(self): - """Test: bazel build --build_tag_filters=compile_commands ...""" - self.check_command( - "bazel build --build_tag_filters=compile_commands ...") - compile_commands = os.path.join( - self.BAZEL_BIN_DIR, "compile_commands_pass", - "compile_commands.json") - self.grep_file(compile_commands, r"pass\.cc") - self.grep_file(compile_commands, r"\/gcc") - def test_bazel_build_clang_tidy_pass(self): """Test: bazel build :clang_tidy_pass""" self.check_command("bazel build :clang_tidy_pass", exit_code=0) @@ -152,10 +130,6 @@ def test_bazel_build_clang_ctu(self): self.check_command( "bazel build :clang_ctu_pass :clang_ctu_fail", exit_code=0) - def test_bazel_test_clang_ctu_pass(self): - """Test: bazel test :clang_ctu_pass""" - self.check_command("bazel test :clang_ctu_pass", exit_code=0) - def test_bazel_test_clang_ctu_fail(self): """Test: bazel test :clang_ctu_fail""" self.check_command("bazel test :clang_ctu_fail", exit_code=3) @@ -163,10 +137,6 @@ def test_bazel_test_clang_ctu_fail(self): self.BAZEL_TESTLOGS_DIR, "clang_ctu_fail", "test.log") self.grep_file(logfile, "// CTU example") - def test_bazel_test_code_checker_pass(self): - """Test: bazel test :code_checker_pass""" - self.check_command("bazel test :code_checker_pass", exit_code=0) - def test_bazel_test_code_checker_fail(self): """Test: bazel test :code_checker_fail""" self.check_command("bazel test :code_checker_fail", exit_code=3) diff --git a/test/unit/unit_test.bzl b/test/unit/unit_test.bzl new file mode 100644 index 00000000..8cbedf68 --- /dev/null +++ b/test/unit/unit_test.bzl @@ -0,0 +1,93 @@ +# Copyright 2026 Ericsson AB +# +# 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. + +""" +Macro for generating unit tests for rules_codechecker. + +Each unit_test() generates a local py_test that: + Depends on an other action, and checks for patterns on its output. + +Example: + unit_test( + name = "my_unit_test", + files = ["my_target_file.ext"], + patterns = ["my_pattern"], + negative_patterns = ["my_negative_pattern"], + regex_patterns = ["my_.*regex.*_pattern"], + negative_regex_patterns = ["my_.*negative_regex.*_pattern"], + ) +""" + +def unit_test( + name, + files, + patterns = None, + negative_patterns = None, + regex_patterns = None, + negative_regex_patterns = None, + any = False, + tags = [], + size = "medium", + **kwargs): + """Generate a py_test that checks if provided patterns are in the files. + + Args: + name: Test name. + files: Path or glob to the files to be checked. + patterns: Patterns that should be inside the files. + negative_patterns: Patterns that shouldn't be inside the files. + regex_patterns: Regex patterns that should be inside the files. + negative_regex_patterns: Regex patterns that shouldn't be inside the files. + any: If enabled its enough if every pattern is found in at least one file. + tags: Additional test tags. + size: Test size (default: medium). + **kwargs: Forwarded to py_test. + """ + if type(files) == "string": + files = [files] + if type(patterns) == "string": + patterns = [patterns] + if type(negative_patterns) == "string": + negative_patterns = [negative_patterns] + if type(regex_patterns) == "string": + regex_patterns = [regex_patterns] + if type(negative_regex_patterns) == "string": + negative_regex_patterns = [negative_regex_patterns] + + python_args = ["--files"] + files + if patterns: + python_args.append("--patterns") + python_args.extend(patterns) + if negative_patterns: + python_args.append("--negative_patterns") + python_args.extend(negative_patterns) + if regex_patterns: + python_args.append("--regex_patterns") + python_args.extend(regex_patterns) + if negative_regex_patterns: + python_args.append("--negative_regex_patterns") + python_args.extend(negative_regex_patterns) + if any: + python_args.append("--any") + + native.py_test( + name = name, + srcs = ["//test/unit:grep_check.py"], + main = "grep_check.py", + args = python_args, + local = True, + tags = ["unit"] + tags, + size = size, + **kwargs + ) diff --git a/test/unit/virtual_include/BUILD b/test/unit/virtual_include/BUILD index beaad605..54b79117 100644 --- a/test/unit/virtual_include/BUILD +++ b/test/unit/virtual_include/BUILD @@ -12,16 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +# cc_binary for simple C++ tests +load( + "@rules_cc//cc:defs.bzl", + "cc_library", +) + # codechecker rules load( "//src:codechecker.bzl", "codechecker_test", ) - -# cc_binary for simple C++ tests load( - "@rules_cc//cc:defs.bzl", - "cc_library", + "//test/unit:unit_test.bzl", + "unit_test", ) # Test for strip_include_prefix @@ -86,3 +90,20 @@ codechecker_test( "virtual_implementation_deps_include", ], ) + +unit_test( + name = "per_file_plist_path_resolved_test", + data = [":per_file_virtual_include"], + files = "test/unit/virtual_include/per_file_virtual_include/**/*.plist", + patterns = r"/_virtual_includes/", +) + +unit_test( + name = "codechecker_plist_path_resolved_test", + any = True, + data = [":codechecker_virtual_include"], + files = "test/unit/virtual_include/codechecker_virtual_include/**/*.plist", + # FIXME: In the postprocessed plists, all _virtual_include paths + # should've been removed. Update to negative_patterns + patterns = r"/_virtual_includes/", +) diff --git a/test/unit/virtual_include/test_virtual_include.py b/test/unit/virtual_include/test_virtual_include.py index 04eb7fb3..8d1b5ca9 100644 --- a/test/unit/virtual_include/test_virtual_include.py +++ b/test/unit/virtual_include/test_virtual_include.py @@ -25,7 +25,6 @@ import logging import os import unittest -import glob from typing import final from common.base import TestBase @@ -67,57 +66,6 @@ def contains_in_files(self, regex, file_list): result.append(file) return result - def test_bazel_per_file_plist_path_resolved(self): - """Test: bazel build :per_file_virtual_include""" - ret, _, stderr = self.run_command( - "bazel build //test/unit/virtual_include:per_file_virtual_include", - ) - self.assertEqual(ret, 0, stderr) - plist_files = glob.glob( - os.path.join( - self.BAZEL_BIN_DIR, # pyright: ignore - "per_file_virtual_include", - "**", - "*.plist", - ), - recursive=True, - ) - # Test whether the _virtual_include directory was actually created. - self.assertTrue( - os.path.isdir(f"{self.BAZEL_BIN_DIR}/_virtual_includes") - ) - # FIXME: In the postprocessed plists, all _virtual_include paths - # should've been removed. Possible fix is in the github PR #14. - self.assertNotEqual( - self.contains_in_files(r"/_virtual_includes/", plist_files), [] - ) - - def test_bazel_codechecker_plist_path_resolved(self): - """Test: bazel build :codechecker_virtual_include""" - ret, _, stderr = self.run_command( - "bazel build " - "//test/unit/virtual_include:codechecker_virtual_include" - ) - self.assertEqual(ret, 0, stderr) - plist_files = glob.glob( - os.path.join( - self.BAZEL_BIN_DIR, # pyright: ignore - "codechecker_virtual_include", - "**", - "*.plist", - ), - recursive=True, - ) - # Test whether the _virtual_include directory was actually created. - self.assertTrue( - os.path.isdir(f"{self.BAZEL_BIN_DIR}/_virtual_includes") - ) - # FIXME: In the postprocessed plists, all _virtual_include paths - # should've been removed. Possible fix is in the github PR #14. - self.assertNotEqual( - self.contains_in_files(r"/_virtual_includes/", plist_files), [] - ) - def test_bazel_codechecker_implementation_deps_virtual_include(self): """Test: bazel build :codechecker_impl_deps_include""" ret, _, stderr = self.run_command(