diff --git a/test/unit/parse/BUILD b/test/unit/parse/BUILD index 2e963862..80ae22f7 100644 --- a/test/unit/parse/BUILD +++ b/test/unit/parse/BUILD @@ -17,6 +17,14 @@ load( "@rules_cc//cc:defs.bzl", "cc_library", ) +load( + "@rules_python//python:py_library.bzl", + "py_library", +) +load( + "@rules_python//python:py_test.bzl", + "py_test", +) load( "//:defs.bzl", "codechecker_test", @@ -29,9 +37,122 @@ cc_library( ) codechecker_test( - name = "codechecker", + name = "codechecker_data", + tags = ["manual"], + targets = [ + "c_target", + ], +) + +codechecker_test( + name = "per_file_data", + per_file = True, tags = ["manual"], targets = [ "c_target", ], ) + +# codechecker_data is a test target, so its own $(rootpaths) resolve to the +# test launcher. This plain filegroup re-exports the analysis output files, +# letting the tests receive their paths from Bazel and pick the report +# directory (codechecker-files) instead of hard-coding it. +filegroup( + name = "report_files", + testonly = True, + srcs = [":codechecker_data"], + tags = ["manual"], +) + +filegroup( + name = "per_file_report_files", + testonly = True, + srcs = [":per_file_data"], + tags = ["manual"], +) + +py_library( + name = "helpers", + srcs = ["helpers.py"], + imports = ["."], +) + +py_test( + name = "parse_test", + srcs = ["parse_check.py"], + args = [ + "--mode=parse", + "$(rootpaths :report_files)", + ], + data = [":report_files"], + local = True, + main = "parse_check.py", + tags = ["unit"], + deps = [":helpers"], +) + +py_test( + name = "parse_html_test", + srcs = ["parse_check.py"], + args = [ + "--mode=parse_html", + "$(rootpaths :report_files)", + ], + data = [":report_files"], + local = True, + main = "parse_check.py", + tags = ["unit"], + deps = [":helpers"], +) + +py_test( + name = "store_test", + size = "large", + srcs = ["store_check.py"], + args = ["$(rootpaths :report_files)"], + data = [":report_files"], + local = True, + main = "store_check.py", + tags = ["unit"], + deps = [":helpers"], +) + +py_test( + name = "per_file_parse_test", + srcs = ["parse_check.py"], + args = [ + "--mode=parse", + "$(rootpaths :per_file_report_files)", + ], + data = [":per_file_report_files"], + local = True, + main = "parse_check.py", + tags = ["unit"], + deps = [":helpers"], +) + +py_test( + name = "per_file_parse_html_test", + srcs = ["parse_check.py"], + args = [ + "--mode=parse_html", + "$(rootpaths :per_file_report_files)", + ], + data = [":per_file_report_files"], + local = True, + main = "parse_check.py", + tags = ["unit"], + deps = [":helpers"], +) + +py_test( + name = "per_file_store_test", + size = "large", + srcs = ["store_check.py"], + args = ["$(rootpaths :per_file_report_files)"], + data = [":per_file_report_files"], + local = True, + main = "store_check.py", + tags = ["unit"], + deps = [":helpers"], +) diff --git a/test/unit/parse/helpers.py b/test/unit/parse/helpers.py new file mode 100644 index 00000000..5a52ad07 --- /dev/null +++ b/test/unit/parse/helpers.py @@ -0,0 +1,62 @@ +# 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. + +""" +Shared helpers for the parse and store verification scripts. +""" + +import os +import subprocess +import sys + +# Basename of the directory holding the analyzer result files +REPORT_DIR_NAME = "data" + + +def resolve_report_data(paths: list[str]) -> str: + """Return the report directory that CodeChecker parse/store consume. + + Bazel passes every output of the analysis target via $(rootpaths). The + reports live in a directory named "data"; depending on the rule it is + either passed directly as a directory artifact (monolithic) or implied by + the individual report files inside it (per-file). + """ + for path in paths: + # Monolithic passes the "codechecker-files" directory artifact; its + # reports live in the "data" subdirectory. + if os.path.isdir(os.path.join(path, REPORT_DIR_NAME)): + return os.path.join(path, REPORT_DIR_NAME) + # Per-file passes individual report files, e.g. .../data/foo.plist; + # derive the enclosing "data" directory from them. + parent = os.path.dirname(path) + if os.path.basename(parent) == REPORT_DIR_NAME and os.path.isdir( + parent + ): + return parent + + print(f"FAILED: no {REPORT_DIR_NAME} report directory in paths: {paths}") + sys.exit(1) + + +def run_codechecker(arguments: list[str]) -> tuple[int, str, str]: + """Run a CodeChecker command and return (exit_code, stdout, stderr).""" + command = ["CodeChecker"] + arguments + print(f"Running: {' '.join(command)}") + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + return result.returncode, result.stdout, result.stderr diff --git a/test/unit/parse/parse_check.py b/test/unit/parse/parse_check.py new file mode 100644 index 00000000..2e21aacf --- /dev/null +++ b/test/unit/parse/parse_check.py @@ -0,0 +1,120 @@ +# 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. + +""" +Self-contained parse verification script for Bazel py_test targets. + +Runs "CodeChecker parse" on the report files produced by a codechecker_test +target that is supplied as a data dependency. Two modes are supported: + + parse Parse the results and assert a bug is reported. + CodeChecker parse exits with code 2 when it finds a report, + and 0 when it finds none. The analyzed source contains a bug, + so exit code 2 is expected. + + parse_html Export the results as an HTML report into a temporary + directory and assert that a non-empty index.html is produced. + +The report directory is passed in by Bazel via $(rootpaths), so no path is +hard-coded and no nested Bazel invocation is required. + +Usage: + python parse_check.py --mode parse + python parse_check.py --mode parse_html +""" + +import argparse +import os +import sys +import tempfile +from helpers import resolve_report_data, run_codechecker + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Verify CodeChecker parse behavior." + ) + parser.add_argument( + "--mode", + required=True, + choices=["parse", "parse_html"], + help="Which parse check to run.", + ) + # This argument is also found in the store_check test + # pylint: disable=duplicate-code + parser.add_argument( + "paths", + nargs="+", + help="Runfiles paths of the analysis target ($(rootpaths ...)); " + "the report directory is selected from them.", + ) + return parser.parse_args() + + +def check_parse(report_dir: str) -> int: + """Parse the results and assert a bug is reported (exit code 2).""" + ret, stdout, stderr = run_codechecker(["parse", report_dir]) + if ret != 2: + print( + f"FAILED: Expected CodeChecker parse to report a bug " + f"(exit code 2), got {ret}" + ) + print(f"stdout:\n{stdout}") + print(f"stderr:\n{stderr}") + return 1 + print("PASSED: CodeChecker parse reported a bug as expected.") + return 0 + + +def check_parse_html(report_dir: str) -> int: + """Export an HTML report and assert a non-empty index.html is produced.""" + with tempfile.TemporaryDirectory() as html_dir: + ret, stdout, stderr = run_codechecker( + ["parse", report_dir, "--export", "html", "--output", html_dir] + ) + # CodeChecker parse exits 2 when reports are found, 0 otherwise; + # both indicate the export ran. Anything else is a real failure. + if ret not in (0, 2): + print( + f"FAILED: CodeChecker parse --export html failed with " + f"exit code {ret}" + ) + print(f"stdout:\n{stdout}") + print(f"stderr:\n{stderr}") + return 1 + index_html = os.path.join(html_dir, "index.html") + if not os.path.isfile(index_html): + print(f"FAILED: HTML report not produced at {index_html}") + print(f"stdout:\n{stdout}") + print(f"stderr:\n{stderr}") + return 1 + if os.path.getsize(index_html) == 0: + print(f"FAILED: HTML report is empty at {index_html}") + return 1 + print("PASSED: CodeChecker parse produced a non-empty HTML report.") + return 0 + + +def main() -> int: + """Entry point.""" + args = parse_args() + report_dir = resolve_report_data(args.paths) + if args.mode == "parse": + return check_parse(report_dir) + return check_parse_html(report_dir) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/common/codechecker_server.py b/test/unit/parse/store_check.py similarity index 52% rename from test/common/codechecker_server.py rename to test/unit/parse/store_check.py index 2944ff9a..25804462 100644 --- a/test/common/codechecker_server.py +++ b/test/unit/parse/store_check.py @@ -1,4 +1,4 @@ -# Copyright 2023 Ericsson AB +# 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. @@ -13,19 +13,34 @@ # limitations under the License. """ -Codechecker server functionality, and related functions +Self-contained store verification script for Bazel py_test targets. + +Starts its own CodeChecker server on a free port, stores the report files +produced by a codechecker_test target (supplied as a data dependency), and +asserts that the store command succeeds. The server is always torn down and +its temporary workspace removed, even on failure. + +The embedded CodeCheckerServer helper (and its supporting functions) is a copy +of test/common/codechecker_server.py so that this test is self-contained and +does not depend on test/common. + +Usage: + python store_check.py --name unit_test_bazel """ +import argparse import os import shutil import signal import socket import subprocess +import sys import tempfile import time import urllib -import urllib.request import urllib.error +import urllib.request +from helpers import resolve_report_data, run_codechecker def _get_free_port(): @@ -73,11 +88,11 @@ def __init__(self, port=None): self.start_codechecker_server() def __del__(self): - self.stop_codechecker_server() + self._stop_codechecker_server() def start_codechecker_server(self): """ - Starts a CodeChecker server instance on a free port + Starts a CodeChecker server instance on a free port This server must be shutdown with stop_codechecker_sever """ if self.running: @@ -102,12 +117,74 @@ def start_codechecker_server(self): ), "Failed to start CodeChecker server" self.running = True - def stop_codechecker_server(self): + def _stop_codechecker_server(self): """ Stops the CodeChecker server started by start_codechecker_server """ + # Avoid killing the process or removing the workspace twice. + if not self.running: + return os.kill(self.server_process.pid, signal.SIGTERM) self.server_process.wait() self.running = False self.devnull.close() shutil.rmtree(self.temp_workspace) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Verify CodeChecker store behavior." + ) + parser.add_argument( + "--name", + default="unit_test_bazel", + help="Project name to store the results under.", + ) + # This argument is also found in the parse_check test + # pylint: disable=duplicate-code + parser.add_argument( + "paths", + nargs="+", + help="Runfiles paths of the analysis target ($(rootpaths ...)); " + "the report directory is selected from them.", + ) + return parser.parse_args() + + +def check_store(report_dir: str, name: str, port: int, zip_loc: str) -> int: + """Store the results and assert the store command succeeds.""" + ret, stdout, stderr = run_codechecker([ + "store", + report_dir, + "-n", name, + f"--url=http://localhost:{port}/Default", + "--zip-loc", zip_loc, + ]) + if ret != 0: + print(f"FAILED: CodeChecker store failed with exit code {ret}") + print(f"stdout:\n{stdout}") + print(f"stderr:\n{stderr}") + return 1 + print("PASSED: CodeChecker store succeeded.") + return 0 + + +def main() -> int: + """Entry point.""" + args = parse_args() + report_dir = resolve_report_data(args.paths) + exit_code = 1 + + # CodeChecker store writes a compressed file while assembling its upload. + # By default that goes into the report directory, which lives in the + # read-only Bazel runfiles tree, so point --zip-loc at a writable dir. + # NOTE: the --zip-loc flag is only available since CodeChecker 6.27.0. + with tempfile.TemporaryDirectory() as zip_loc: + server = CodeCheckerServer() + exit_code = check_store(report_dir, args.name, server.port, zip_loc) + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/unit/parse/test_parse.py b/test/unit/parse/test_parse.py deleted file mode 100644 index ba3a41d6..00000000 --- a/test/unit/parse/test_parse.py +++ /dev/null @@ -1,108 +0,0 @@ -# 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. - -""" -Test wether CodeChecker parse and CodeChecker store -runs correctly on the produced report files -""" -import os -import unittest -from typing import final -from common.base import TestBase -from common.codechecker_server import CodeCheckerServer - - -class TestTemplate(TestBase): - """Test CodeChecker parse, store""" - - # Set working directory - __test_path__ = os.path.dirname(os.path.abspath(__file__)) - BAZEL_BIN_DIR = os.path.join( - "../../..", "bazel-bin", "test", "unit", "parse" - ) - BAZEL_TESTLOGS_DIR = os.path.join( - "../../..", "bazel-testlogs", "test", "unit", "parse" - ) - - @final - @classmethod - def setUpClass(cls): - """Start CodeChecker server""" - super().setUpClass() - cls.codechecker_server = CodeCheckerServer() - - @final - @classmethod - def tearDownClass(cls): - """Stop CodeChecker server""" - del cls.codechecker_server - super().tearDownClass() - - def check_store(self, path: str, name: str): - """ - Tries to store the results on the codechecker server, - asserts for successful storing. - - Args: - path - Path of the result files - name - name of the project to be saved under - """ - port = getattr(self.codechecker_server, 'port', 8001) - ret, stdout, stderr = self.run_command( - f"CodeChecker store {path} -n {name}" - f" --url=http://localhost:{port}/Default" - ) - self.assertEqual(ret, 0, stdout + "\n" + stderr) - - def check_parse(self, path: str, will_find_bug: bool = True): - """ - Checks if the parse command finishes correctly on results. - - Args: - path - Path of the result files - will_find_bug - Will there be a bug in the result files, - changes on what we assert - """ - ret, _, _ = self.run_command(f"CodeChecker parse {path}") - self.assertEqual(ret, 2 if will_find_bug else 0) - - def test_parse_html(self): - """Test: Parse results into html""" - ret, _, stderr = self.run_command( - "bazel build //test/unit/parse:codechecker" - ) - self.assertEqual(ret, 0, stderr) - self.check_parse( - f"{self.BAZEL_BIN_DIR}/codechecker/codechecker-files/data" - ) - - def test_store(self): - """Test: Storing to CodeChecker server""" - # FIXME: CodeChecker store wants to create a temporary folder inside - # the report folder. Bazel's output folder however is readonly. - # Adding the flag: "--experimental_writable_outputs" - # makes the directory writeable - ret, _, stderr = self.run_command( - "bazel build //test/unit/parse:codechecker " - "--experimental_writable_outputs" - ) - self.assertEqual(ret, 0, stderr) - self.check_store( - f"{self.BAZEL_BIN_DIR}/codechecker/codechecker-files/data", - "unit_test_bazel", - ) - - -if __name__ == "__main__": - unittest.main(buffer=True)