From ecceae8527e0814cc0c3a5795f52ad2076d9f1ff Mon Sep 17 00:00:00 2001 From: "F.Tibor" Date: Tue, 8 Sep 2026 09:50:36 +0200 Subject: [PATCH 1/6] Migrate parse test into bazel --- test/common/codechecker_server.py | 113 --------------- test/unit/parse/BUILD | 54 +++++++- test/unit/parse/parse_check.py | 152 ++++++++++++++++++++ test/unit/parse/store_check.py | 223 ++++++++++++++++++++++++++++++ test/unit/parse/test_parse.py | 108 --------------- 5 files changed, 428 insertions(+), 222 deletions(-) delete mode 100644 test/common/codechecker_server.py create mode 100644 test/unit/parse/parse_check.py create mode 100644 test/unit/parse/store_check.py delete mode 100644 test/unit/parse/test_parse.py diff --git a/test/common/codechecker_server.py b/test/common/codechecker_server.py deleted file mode 100644 index 2944ff9a..00000000 --- a/test/common/codechecker_server.py +++ /dev/null @@ -1,113 +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. - -""" -Codechecker server functionality, and related functions -""" - -import os -import shutil -import signal -import socket -import subprocess -import tempfile -import time -import urllib -import urllib.request -import urllib.error - - -def _get_free_port(): - """ - Return a port number that is free - """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - return s.getsockname()[1] - - -def wait_codechecker_server( - product: str = "Default", - host: str = "localhost", - port: int = 8001, - timeout: int = 10000, - attempt_every: int = 100, -) -> bool: - """ - Wait until the product is available in the CodeChecker server - """ - start = time.monotonic() - url = f"http://{host}:{port}/{product}" - while time.monotonic() - start < timeout / 1000: - try: - with urllib.request.urlopen(url, timeout=timeout / 1000) as resp: - if resp.getcode() == 200: - return True - except (urllib.error.URLError, urllib.error.HTTPError): - pass - time.sleep(attempt_every / 1000) - return False - - -class CodeCheckerServer: - """ - CodeCheckerServer object for testing. - Cleans up after itself. - """ - - def __init__(self, port=None): - self.running = False - self.port = port if port else _get_free_port() - self.temp_workspace = tempfile.mkdtemp() - self.start_codechecker_server() - - def __del__(self): - self.stop_codechecker_server() - - def start_codechecker_server(self): - """ - Starts a CodeChecker server instance on a free port - This server must be shutdown with stop_codechecker_sever - """ - if self.running: - return - server_command = [ - "CodeChecker", - "server", - "--workspace", - self.temp_workspace, - "--port", - str(self.port), - ] - # These file/popen processes are closed when the object dies - # pylint: disable=consider-using-with - self.devnull = open(os.devnull, "w", encoding="utf-8") - # pylint: disable=consider-using-with - self.server_process: subprocess.Popen = subprocess.Popen( - server_command, stdout=self.devnull - ) - assert wait_codechecker_server( - port=self.port, timeout=10000 - ), "Failed to start CodeChecker server" - self.running = True - - def stop_codechecker_server(self): - """ - Stops the CodeChecker server started by start_codechecker_server - """ - os.kill(self.server_process.pid, signal.SIGTERM) - self.server_process.wait() - self.running = False - self.devnull.close() - shutil.rmtree(self.temp_workspace) diff --git a/test/unit/parse/BUILD b/test/unit/parse/BUILD index 2e963862..751fc591 100644 --- a/test/unit/parse/BUILD +++ b/test/unit/parse/BUILD @@ -17,6 +17,10 @@ load( "@rules_cc//cc:defs.bzl", "cc_library", ) +load( + "@rules_python//python:py_test.bzl", + "py_test", +) load( "//:defs.bzl", "codechecker_test", @@ -29,9 +33,57 @@ cc_library( ) codechecker_test( - name = "codechecker", + name = "codechecker_data", 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"], +) + +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"], +) + +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"], +) + +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"], +) diff --git a/test/unit/parse/parse_check.py b/test/unit/parse/parse_check.py new file mode 100644 index 00000000..ddb70aaf --- /dev/null +++ b/test/unit/parse/parse_check.py @@ -0,0 +1,152 @@ +# 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 subprocess +import sys +import tempfile + +# Basename of the analysis output directory produced by codechecker_test. +REPORT_DIR_NAME = "codechecker-files" + + +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.", + ) + 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 resolve_report_data(paths: list[str]) -> str: + """Return the data subdirectory that CodeChecker parse consumes. + + Bazel passes every output of the analysis target via $(rootpaths); the + report directory is the one ending in "codechecker-files", and the plist + reports live in its "data" subdirectory. + """ + report_dirs = [p for p in paths if os.path.basename(p) == REPORT_DIR_NAME] + if not report_dirs: + print(f"FAILED: no {REPORT_DIR_NAME} directory in paths: {paths}") + sys.exit(1) + data_dir = os.path.join(report_dirs[0], "data") + if not os.path.isdir(data_dir): + print(f"FAILED: report data directory not found at {data_dir}") + sys.exit(1) + return data_dir + + +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 + + +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/unit/parse/store_check.py b/test/unit/parse/store_check.py new file mode 100644 index 00000000..31202c32 --- /dev/null +++ b/test/unit/parse/store_check.py @@ -0,0 +1,223 @@ +# 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 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.error +import urllib.request + +# Basename of the analysis output directory produced by codechecker_test. +REPORT_DIR_NAME = "codechecker-files" + + +def _get_free_port(): + """ + Return a port number that is free + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def wait_codechecker_server( + product: str = "Default", + host: str = "localhost", + port: int = 8001, + timeout: int = 10000, + attempt_every: int = 100, +) -> bool: + """ + Wait until the product is available in the CodeChecker server + """ + start = time.monotonic() + url = f"http://{host}:{port}/{product}" + while time.monotonic() - start < timeout / 1000: + try: + with urllib.request.urlopen(url, timeout=timeout / 1000) as resp: + if resp.getcode() == 200: + return True + except (urllib.error.URLError, urllib.error.HTTPError): + pass + time.sleep(attempt_every / 1000) + return False + + +class CodeCheckerServer: + """ + CodeCheckerServer object for testing. + Cleans up after itself. + """ + + def __init__(self, port=None): + self.running = False + self.port = port if port else _get_free_port() + self.temp_workspace = tempfile.mkdtemp() + self.start_codechecker_server() + + def __del__(self): + self.stop_codechecker_server() + + def start_codechecker_server(self): + """ + Starts a CodeChecker server instance on a free port + This server must be shutdown with stop_codechecker_sever + """ + if self.running: + return + server_command = [ + "CodeChecker", + "server", + "--workspace", + self.temp_workspace, + "--port", + str(self.port), + ] + # These file/popen processes are closed when the object dies + # pylint: disable=consider-using-with + self.devnull = open(os.devnull, "w", encoding="utf-8") + # pylint: disable=consider-using-with + self.server_process: subprocess.Popen = subprocess.Popen( + server_command, stdout=self.devnull + ) + assert wait_codechecker_server( + port=self.port, timeout=10000 + ), "Failed to start CodeChecker server" + self.running = True + + def stop_codechecker_server(self): + """ + Stops the CodeChecker server started by start_codechecker_server + """ + # Idempotent: the object is also stopped from __del__, so 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.", + ) + 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 resolve_report_data(paths: list[str]) -> str: + """Return the data subdirectory that CodeChecker store consumes. + + Bazel passes every output of the analysis target via $(rootpaths); the + report directory is the one ending in "codechecker-files", and the plist + reports live in its "data" subdirectory. + """ + report_dirs = [p for p in paths if os.path.basename(p) == REPORT_DIR_NAME] + if not report_dirs: + print(f"FAILED: no {REPORT_DIR_NAME} directory in paths: {paths}") + sys.exit(1) + data_dir = os.path.join(report_dirs[0], "data") + if not os.path.isdir(data_dir): + print(f"FAILED: report data directory not found at {data_dir}") + sys.exit(1) + return data_dir + + +def check_store(report_dir: str, name: str, port: int, zip_loc: str) -> int: + """Store the results and assert the store command succeeds.""" + command = [ + "CodeChecker", + "store", + report_dir, + "-n", + name, + f"--url=http://localhost:{port}/Default", + "--zip-loc", + zip_loc, + ] + print(f"Running: {' '.join(command)}") + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print( + f"FAILED: CodeChecker store failed with exit code " + f"{result.returncode}" + ) + print(f"stdout:\n{result.stdout}") + print(f"stderr:\n{result.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) + + # 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() + try: + return check_store(report_dir, args.name, server.port, zip_loc) + finally: + server.stop_codechecker_server() + + +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) From ce5ec7be8c2f724bba53d3b3c25c714a046a4010 Mon Sep 17 00:00:00 2001 From: "F.Tibor" Date: Tue, 8 Sep 2026 11:11:42 +0200 Subject: [PATCH 2/6] Remove code duplication --- test/unit/parse/BUILD | 13 ++++++++ test/unit/parse/helpers.py | 56 ++++++++++++++++++++++++++++++++++ test/unit/parse/parse_check.py | 41 +++++-------------------- test/unit/parse/store_check.py | 54 ++++++++++---------------------- 4 files changed, 92 insertions(+), 72 deletions(-) create mode 100644 test/unit/parse/helpers.py diff --git a/test/unit/parse/BUILD b/test/unit/parse/BUILD index 751fc591..c52b4a1b 100644 --- a/test/unit/parse/BUILD +++ b/test/unit/parse/BUILD @@ -17,6 +17,10 @@ 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", @@ -62,6 +66,7 @@ py_test( local = True, main = "parse_check.py", tags = ["unit"], + deps = [":helpers"], ) py_test( @@ -75,6 +80,7 @@ py_test( local = True, main = "parse_check.py", tags = ["unit"], + deps = [":helpers"], ) py_test( @@ -86,4 +92,11 @@ py_test( local = True, main = "store_check.py", tags = ["unit"], + deps = [":helpers"], +) + +py_library( + name = "helpers", + srcs = ["helpers.py"], + imports = ["."], ) diff --git a/test/unit/parse/helpers.py b/test/unit/parse/helpers.py new file mode 100644 index 00000000..11545a16 --- /dev/null +++ b/test/unit/parse/helpers.py @@ -0,0 +1,56 @@ +# 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 analysis output directory produced by codechecker_test. +REPORT_DIR_NAME = "codechecker-files" + +def resolve_report_data(paths: list[str]) -> str: + """Return the data subdirectory that CodeChecker parse consumes. + + Bazel passes every output of the analysis target via $(rootpaths); the + report directory is the one ending in "codechecker-files", and the plist + reports live in its "data" subdirectory. + """ + # pylint: disable=duplicate-code + report_dirs = [p for p in paths if os.path.basename(p) == REPORT_DIR_NAME] + if not report_dirs: + print(f"FAILED: no {REPORT_DIR_NAME} directory in paths: {paths}") + sys.exit(1) + data_dir = os.path.join(report_dirs[0], "data") + if not os.path.isdir(data_dir): + print(f"FAILED: report data directory not found at {data_dir}") + sys.exit(1) + return data_dir + + +def run_codechecker(arguments: list[str]) -> tuple[int, str, str]: + """Run a CodeChecker command and return (exit_code, stdout, stderr).""" + # pylint: disable=duplicate-code + 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 index ddb70aaf..d6a9b85e 100644 --- a/test/unit/parse/parse_check.py +++ b/test/unit/parse/parse_check.py @@ -36,12 +36,14 @@ import argparse import os -import subprocess import sys import tempfile -# Basename of the analysis output directory produced by codechecker_test. -REPORT_DIR_NAME = "codechecker-files" +# helpers is the ":helpers" py_library, imported as a top-level module at +# runtime under Bazel. pylint runs outside Bazel and cannot resolve it +# statically, so silence the false positive here. +# pylint: disable=import-error +from helpers import resolve_report_data, run_codechecker def parse_args() -> argparse.Namespace: @@ -55,6 +57,8 @@ def parse_args() -> argparse.Namespace: 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="+", @@ -64,37 +68,6 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def resolve_report_data(paths: list[str]) -> str: - """Return the data subdirectory that CodeChecker parse consumes. - - Bazel passes every output of the analysis target via $(rootpaths); the - report directory is the one ending in "codechecker-files", and the plist - reports live in its "data" subdirectory. - """ - report_dirs = [p for p in paths if os.path.basename(p) == REPORT_DIR_NAME] - if not report_dirs: - print(f"FAILED: no {REPORT_DIR_NAME} directory in paths: {paths}") - sys.exit(1) - data_dir = os.path.join(report_dirs[0], "data") - if not os.path.isdir(data_dir): - print(f"FAILED: report data directory not found at {data_dir}") - sys.exit(1) - return data_dir - - -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 - - 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]) diff --git a/test/unit/parse/store_check.py b/test/unit/parse/store_check.py index 31202c32..7df50677 100644 --- a/test/unit/parse/store_check.py +++ b/test/unit/parse/store_check.py @@ -41,8 +41,11 @@ import urllib.error import urllib.request -# Basename of the analysis output directory produced by codechecker_test. -REPORT_DIR_NAME = "codechecker-files" +# helpers is the ":helpers" py_library, imported as a top-level module at +# runtime under Bazel. pylint runs outside Bazel and cannot resolve it +# statically, so silence the false positive here. +# pylint: disable=import-error +from helpers import resolve_report_data, run_codechecker def _get_free_port(): @@ -144,6 +147,8 @@ def parse_args() -> argparse.Namespace: 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="+", @@ -153,28 +158,9 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def resolve_report_data(paths: list[str]) -> str: - """Return the data subdirectory that CodeChecker store consumes. - - Bazel passes every output of the analysis target via $(rootpaths); the - report directory is the one ending in "codechecker-files", and the plist - reports live in its "data" subdirectory. - """ - report_dirs = [p for p in paths if os.path.basename(p) == REPORT_DIR_NAME] - if not report_dirs: - print(f"FAILED: no {REPORT_DIR_NAME} directory in paths: {paths}") - sys.exit(1) - data_dir = os.path.join(report_dirs[0], "data") - if not os.path.isdir(data_dir): - print(f"FAILED: report data directory not found at {data_dir}") - sys.exit(1) - return data_dir - - def check_store(report_dir: str, name: str, port: int, zip_loc: str) -> int: """Store the results and assert the store command succeeds.""" - command = [ - "CodeChecker", + ret, stdout, stderr = run_codechecker([ "store", report_dir, "-n", @@ -182,21 +168,11 @@ def check_store(report_dir: str, name: str, port: int, zip_loc: str) -> int: f"--url=http://localhost:{port}/Default", "--zip-loc", zip_loc, - ] - print(f"Running: {' '.join(command)}") - result = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - print( - f"FAILED: CodeChecker store failed with exit code " - f"{result.returncode}" - ) - print(f"stdout:\n{result.stdout}") - print(f"stderr:\n{result.stderr}") + ]) + 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 @@ -206,6 +182,7 @@ 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 @@ -214,9 +191,10 @@ def main() -> int: with tempfile.TemporaryDirectory() as zip_loc: server = CodeCheckerServer() try: - return check_store(report_dir, args.name, server.port, zip_loc) + exit_code = check_store(report_dir, args.name, server.port, zip_loc) finally: server.stop_codechecker_server() + return exit_code if __name__ == "__main__": From 0052a8fc715a8434e2ac18a6b05b6fd5f1cbae7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20F=C3=BCr=C3=A9sz?= Date: Thu, 1 Oct 2026 14:31:47 +0200 Subject: [PATCH 3/6] Update test/unit/parse/store_check.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kristóf Umann --- test/unit/parse/store_check.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/unit/parse/store_check.py b/test/unit/parse/store_check.py index 7df50677..307115ed 100644 --- a/test/unit/parse/store_check.py +++ b/test/unit/parse/store_check.py @@ -163,11 +163,9 @@ def check_store(report_dir: str, name: str, port: int, zip_loc: str) -> int: ret, stdout, stderr = run_codechecker([ "store", report_dir, - "-n", - name, + "-n", name, f"--url=http://localhost:{port}/Default", - "--zip-loc", - zip_loc, + "--zip-loc", zip_loc, ]) if ret != 0: print(f"FAILED: CodeChecker store failed with exit code {ret}") From d1111036653990c4f36b53dac1845a5befc5e500 Mon Sep 17 00:00:00 2001 From: "F.Tibor" Date: Thu, 1 Oct 2026 14:39:46 +0200 Subject: [PATCH 4/6] Rewrite how codechecker server handled and removed pylint ignores --- test/unit/parse/helpers.py | 2 -- test/unit/parse/parse_check.py | 5 ----- test/unit/parse/store_check.py | 17 ++++------------- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/test/unit/parse/helpers.py b/test/unit/parse/helpers.py index 11545a16..4482a695 100644 --- a/test/unit/parse/helpers.py +++ b/test/unit/parse/helpers.py @@ -30,7 +30,6 @@ def resolve_report_data(paths: list[str]) -> str: report directory is the one ending in "codechecker-files", and the plist reports live in its "data" subdirectory. """ - # pylint: disable=duplicate-code report_dirs = [p for p in paths if os.path.basename(p) == REPORT_DIR_NAME] if not report_dirs: print(f"FAILED: no {REPORT_DIR_NAME} directory in paths: {paths}") @@ -44,7 +43,6 @@ def resolve_report_data(paths: list[str]) -> str: def run_codechecker(arguments: list[str]) -> tuple[int, str, str]: """Run a CodeChecker command and return (exit_code, stdout, stderr).""" - # pylint: disable=duplicate-code command = ["CodeChecker"] + arguments print(f"Running: {' '.join(command)}") result = subprocess.run( diff --git a/test/unit/parse/parse_check.py b/test/unit/parse/parse_check.py index d6a9b85e..2e21aacf 100644 --- a/test/unit/parse/parse_check.py +++ b/test/unit/parse/parse_check.py @@ -38,11 +38,6 @@ import os import sys import tempfile - -# helpers is the ":helpers" py_library, imported as a top-level module at -# runtime under Bazel. pylint runs outside Bazel and cannot resolve it -# statically, so silence the false positive here. -# pylint: disable=import-error from helpers import resolve_report_data, run_codechecker diff --git a/test/unit/parse/store_check.py b/test/unit/parse/store_check.py index 307115ed..25804462 100644 --- a/test/unit/parse/store_check.py +++ b/test/unit/parse/store_check.py @@ -40,11 +40,6 @@ import urllib import urllib.error import urllib.request - -# helpers is the ":helpers" py_library, imported as a top-level module at -# runtime under Bazel. pylint runs outside Bazel and cannot resolve it -# statically, so silence the false positive here. -# pylint: disable=import-error from helpers import resolve_report_data, run_codechecker @@ -93,7 +88,7 @@ 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): """ @@ -122,12 +117,11 @@ 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 """ - # Idempotent: the object is also stopped from __del__, so avoid - # killing the process or removing the workspace twice. + # Avoid killing the process or removing the workspace twice. if not self.running: return os.kill(self.server_process.pid, signal.SIGTERM) @@ -188,10 +182,7 @@ def main() -> int: # NOTE: the --zip-loc flag is only available since CodeChecker 6.27.0. with tempfile.TemporaryDirectory() as zip_loc: server = CodeCheckerServer() - try: - exit_code = check_store(report_dir, args.name, server.port, zip_loc) - finally: - server.stop_codechecker_server() + exit_code = check_store(report_dir, args.name, server.port, zip_loc) return exit_code From 2bc4e506a82ddd7ac129c0190e1db6794e3de55b Mon Sep 17 00:00:00 2001 From: "F.Tibor" Date: Mon, 5 Oct 2026 08:48:18 +0200 Subject: [PATCH 5/6] Add per_file tests --- test/unit/parse/BUILD | 64 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/test/unit/parse/BUILD b/test/unit/parse/BUILD index c52b4a1b..80ae22f7 100644 --- a/test/unit/parse/BUILD +++ b/test/unit/parse/BUILD @@ -44,6 +44,15 @@ codechecker_test( ], ) +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 @@ -55,6 +64,19 @@ filegroup( 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"], @@ -95,8 +117,42 @@ py_test( deps = [":helpers"], ) -py_library( - name = "helpers", - srcs = ["helpers.py"], - imports = ["."], +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"], ) From ff68ed38f94f9164b0fdf5d9ca38cdfc916a445e Mon Sep 17 00:00:00 2001 From: "F.Tibor" Date: Mon, 5 Oct 2026 09:19:10 +0200 Subject: [PATCH 6/6] Fix resolve_report_data function to support both monolithic and per_file --- test/unit/parse/helpers.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/test/unit/parse/helpers.py b/test/unit/parse/helpers.py index 4482a695..5a52ad07 100644 --- a/test/unit/parse/helpers.py +++ b/test/unit/parse/helpers.py @@ -20,25 +20,33 @@ import subprocess import sys -# Basename of the analysis output directory produced by codechecker_test. -REPORT_DIR_NAME = "codechecker-files" +# Basename of the directory holding the analyzer result files +REPORT_DIR_NAME = "data" + def resolve_report_data(paths: list[str]) -> str: - """Return the data subdirectory that CodeChecker parse consumes. + """Return the report directory that CodeChecker parse/store consume. - Bazel passes every output of the analysis target via $(rootpaths); the - report directory is the one ending in "codechecker-files", and the plist - reports live in its "data" subdirectory. + 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). """ - report_dirs = [p for p in paths if os.path.basename(p) == REPORT_DIR_NAME] - if not report_dirs: - print(f"FAILED: no {REPORT_DIR_NAME} directory in paths: {paths}") - sys.exit(1) - data_dir = os.path.join(report_dirs[0], "data") - if not os.path.isdir(data_dir): - print(f"FAILED: report data directory not found at {data_dir}") - sys.exit(1) - return data_dir + 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]: