85a0ff93c2
cpp-template / format (ubuntu-22.04) (push) Successful in 57s
cpp-template / format (ubuntu-24.04) (push) Successful in 56s
cpp-template / build-test (asan, ubuntu-22.04) (push) Successful in 1m34s
cpp-template / build-test (asan, ubuntu-24.04) (push) Successful in 1m35s
cpp-template / build-test (debug, ubuntu-22.04) (push) Successful in 1m55s
cpp-template / build-test (debug, ubuntu-24.04) (push) Successful in 1m50s
cpp-template / build-test (fuzz, ubuntu-22.04) (push) Successful in 6m18s
cpp-template / build-test (fuzz, ubuntu-24.04) (push) Successful in 7m0s
cpp-template / build-test (release, ubuntu-22.04) (push) Successful in 1m49s
cpp-template / build-test (release, ubuntu-24.04) (push) Successful in 1m53s
cpp-template / clang-tidy (ubuntu-22.04) (push) Successful in 1m37s
cpp-template / clang-tidy (ubuntu-24.04) (push) Successful in 1m52s
cpp-template / install-consumer (ubuntu-22.04) (push) Successful in 1m41s
cpp-template / install-consumer (ubuntu-24.04) (push) Successful in 1m36s
cpp-template / no-network-negative (ubuntu-22.04) (push) Successful in 6m50s
cpp-template / no-network-negative (ubuntu-24.04) (push) Successful in 7m32s
cpp-template / format (ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (asan, ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (debug, ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (fuzz, ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (release, ubuntu-20.04) (push) Has been cancelled
cpp-template / clang-tidy (ubuntu-20.04) (push) Has been cancelled
cpp-template / install-consumer (ubuntu-20.04) (push) Has been cancelled
cpp-template / no-network-negative (ubuntu-20.04) (push) Has been cancelled
781 lines
28 KiB
Python
781 lines
28 KiB
Python
"""CMake helper API fixture runner for dev_check.py.
|
|
|
|
Discovers and runs positive/negative CMake helper API fixture directories,
|
|
verifying configure success (positive) or expected diagnostic substrings
|
|
(negative). Build directories live under build/cmake-helper-fixtures/<case-id>.
|
|
|
|
Also provides install-consumer QA: two-phase flow that installs a fixture
|
|
package into a temp prefix, then configures/builds/runs a separate consumer
|
|
using only find_package(CONFIG REQUIRED), with source-tree leakage checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import pathlib
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from collections.abc import Sequence
|
|
|
|
from lib import common
|
|
|
|
STATUS_PASS = "PASS"
|
|
STATUS_FAIL = "FAIL"
|
|
STATUS_DEFERRED = "DEFERRED"
|
|
|
|
FIXTURES_DIR_NAME = "helper-api"
|
|
BUILD_SUBDIR = "cmake-helper-fixtures"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Negative fixture expected diagnostic substrings.
|
|
# Key = fixture directory name (e.g. "02-cc-library-missing-type").
|
|
# Value = substring that must appear in cmake stderr when the fixture fails.
|
|
# ---------------------------------------------------------------------------
|
|
NEGATIVE_DIAGNOSTICS: dict[str, str] = {
|
|
"01-cc-project-no-project-call": "cc_project() requires a prior project() call",
|
|
"02-cc-library-missing-type": "TYPE is required",
|
|
"03-cc-library-unqualified-deps": "appears without a visibility keyword",
|
|
"04-cc-library-interface-public": "INTERFACE libraries do not support PUBLIC",
|
|
"05-cc-executable-interface-visibility": "INTERFACE dependencies are not valid for executable targets",
|
|
"06-cc-executable-unqualified-deps": "appears without a visibility keyword",
|
|
"07-cc-executable-invalid-alias": "is not a valid CMake target name",
|
|
"08-cc-test-unqualified-deps": "appears without a visibility keyword",
|
|
"09-cc-test-interface-visibility": "INTERFACE dependencies are not valid for test targets",
|
|
"10-cc-test-gmock-main-without-no-main": "GTest::gmock_main requires NO_MAIN",
|
|
"11-cc-test-missing-srcs": "SRCS is required and must be non-empty",
|
|
"12-cc-benchmark-missing-srcs": "SRCS is required and must be non-empty",
|
|
"13-cc-benchmark-unqualified-deps": "appears without a visibility keyword",
|
|
"14-cc-benchmark-interface-visibility": "INTERFACE dependencies are not valid for benchmark targets",
|
|
"15-cc-benchmark-unknown-args": "unknown arguments",
|
|
"16-cc-library-object-type": "TYPE must be STATIC, SHARED, or INTERFACE",
|
|
|
|
# Negative cc_fuzz fixtures (T14)
|
|
"17-cc-fuzz-missing-srcs": "SRCS is required and must be non-empty",
|
|
"18-cc-fuzz-interface-visibility": "INTERFACE dependencies are not valid for fuzz targets",
|
|
"19-cc-fuzz-unknown-args": "unknown arguments",
|
|
"20-cc-fuzz-required-unavailable": "REQUIRED but FuzzTest is not available",
|
|
# Negative install-export fixtures
|
|
# Negative install-export fixtures
|
|
"01-install-missing-alias": "has no ALIAS",
|
|
"02-install-invalid-alias": "must contain exactly one",
|
|
"03-install-non-namespaced-alias": "is not namespaced",
|
|
|
|
# Negative install-export fixtures (T25 dependency registry)
|
|
"04-install-unknown-dep": "reference unknown install-time dependency",
|
|
"05-install-dep-no-package": "PACKAGE is required",
|
|
"06-install-static-private-project-not-installed": "PRIVATE project-target dependency",
|
|
|
|
# Negative fuzz-smoke fixtures (T35/T36)
|
|
"gtest-isolation": "Build tree already has normal-lane",
|
|
"fuzztest-optional-features": "FuzzTest patched mode forbids optional dependency target",
|
|
}
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class FixtureResult:
|
|
"""Outcome of running a single fixture."""
|
|
|
|
fixture_id: str
|
|
status: str # PASS / FAIL / DEFERRED
|
|
message: str
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class InstallConsumerResult:
|
|
"""Outcome of running an install-consumer QA test."""
|
|
|
|
fixture_id: str
|
|
status: str # PASS / FAIL / DEFERRED
|
|
message: str
|
|
log_dir: pathlib.Path | None = None
|
|
|
|
def _fixture_base_dir(project_root: pathlib.Path) -> pathlib.Path:
|
|
return project_root / "cmake" / "tests" / "fixtures"
|
|
|
|
|
|
def _build_root(project_root: pathlib.Path) -> pathlib.Path:
|
|
return project_root / "build" / BUILD_SUBDIR
|
|
|
|
|
|
def discover_positive_fixtures(
|
|
project_root: pathlib.Path,
|
|
) -> list[pathlib.Path]:
|
|
"""Return sorted list of positive helper-api fixture directories."""
|
|
base = _fixture_base_dir(project_root) / "positive" / FIXTURES_DIR_NAME
|
|
if not base.is_dir():
|
|
return []
|
|
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
|
|
|
|
|
|
def discover_subproject_fixtures(
|
|
project_root: pathlib.Path,
|
|
) -> list[pathlib.Path]:
|
|
"""Return sorted list of positive subproject fixture directories."""
|
|
base = _fixture_base_dir(project_root) / "positive" / "subproject"
|
|
if not base.is_dir():
|
|
return []
|
|
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
|
|
|
|
def discover_install_export_fixtures(
|
|
project_root: pathlib.Path,
|
|
) -> list[pathlib.Path]:
|
|
"""Return sorted list of positive install-export fixture directories."""
|
|
base = _fixture_base_dir(project_root) / "positive" / "install-export"
|
|
if not base.is_dir():
|
|
return []
|
|
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
|
|
|
|
def discover_negative_fixtures(
|
|
project_root: pathlib.Path,
|
|
) -> list[pathlib.Path]:
|
|
"""Return sorted list of negative helper-api fixture directories."""
|
|
base = _fixture_base_dir(project_root) / "negative" / FIXTURES_DIR_NAME
|
|
if not base.is_dir():
|
|
return []
|
|
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
|
|
|
|
def discover_negative_install_export_fixtures(
|
|
project_root: pathlib.Path,
|
|
) -> list[pathlib.Path]:
|
|
"""Return sorted list of negative install-export fixture directories."""
|
|
base = _fixture_base_dir(project_root) / "negative" / "install-export"
|
|
if not base.is_dir():
|
|
return []
|
|
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
|
|
|
|
def discover_negative_fuzz_smoke_fixtures(
|
|
project_root: pathlib.Path,
|
|
) -> list[pathlib.Path]:
|
|
"""Return sorted list of negative fuzz-smoke fixture directories."""
|
|
base = _fixture_base_dir(project_root) / "negative" / "fuzz-smoke"
|
|
if not base.is_dir():
|
|
return []
|
|
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
|
|
|
|
|
|
def _case_id(fixture_path: pathlib.Path) -> str:
|
|
"""Derive a stable case ID from the fixture directory name."""
|
|
return fixture_path.name
|
|
|
|
|
|
def discover_install_consumer_fixtures(
|
|
project_root: pathlib.Path,
|
|
) -> list[pathlib.Path]:
|
|
"""Return sorted list of install-consumer fixture directories.
|
|
|
|
Each fixture directory must contain package/ and consumer/ subdirectories.
|
|
"""
|
|
base = _fixture_base_dir(project_root) / "positive" / "install-consumer"
|
|
if not base.is_dir():
|
|
return []
|
|
return sorted(
|
|
p for p in base.iterdir()
|
|
if p.is_dir()
|
|
and (p / "package" / "CMakeLists.txt").is_file()
|
|
and (p / "consumer" / "CMakeLists.txt").is_file()
|
|
)
|
|
|
|
|
|
def _check_source_tree_leakage(
|
|
source_root: pathlib.Path,
|
|
consumer_build_dir: pathlib.Path,
|
|
install_prefix: pathlib.Path,
|
|
consumer_source_dir: pathlib.Path,
|
|
) -> str | None:
|
|
"""Check consumer build artifacts for unexpected project-root references.
|
|
|
|
Scans CMakeCache.txt and compile_commands.json for the project root path.
|
|
Allows only the consumer's own source dir, its build dir, and the install
|
|
prefix — any other project-root reference (e.g. package source/build)
|
|
is flagged as leakage.
|
|
|
|
Returns a diagnostic string if leakage is detected, or None if clean.
|
|
"""
|
|
project_prefix = str(source_root)
|
|
if not project_prefix.endswith('/'):
|
|
project_prefix += '/'
|
|
|
|
# Known-safe paths that legitimately appear under the project root
|
|
build_output_dir = str(source_root / "build")
|
|
allowlist = [
|
|
# Consumer's own source directory (fixture path)
|
|
str(consumer_source_dir) + "/",
|
|
str(consumer_source_dir),
|
|
# Consumer's own build directory
|
|
str(consumer_build_dir) + "/",
|
|
str(consumer_build_dir),
|
|
# Install prefix (under build/ by fixture convention)
|
|
str(install_prefix) + "/",
|
|
str(install_prefix),
|
|
# Build output directory tree
|
|
build_output_dir + "/",
|
|
build_output_dir,
|
|
]
|
|
|
|
def _is_line_allowed(line: str) -> bool:
|
|
"""Check if a line referencing the project root is allowed."""
|
|
stripped = line.lstrip()
|
|
# Skip CMake comments (metadata, not build logic)
|
|
if stripped.startswith('#'):
|
|
return True
|
|
# References to CC_PROJECT_ROOT in cmake invocations are expected
|
|
if "-DCC_PROJECT_ROOT=" in line:
|
|
return True
|
|
# Check allowlist
|
|
for allowed in allowlist:
|
|
if allowed in line:
|
|
return True
|
|
return False
|
|
|
|
# Check CMake cache for unexpected project-root references
|
|
cache_file = consumer_build_dir / "CMakeCache.txt"
|
|
if cache_file.is_file():
|
|
cache_content = cache_file.read_text(encoding="utf-8", errors="replace")
|
|
for line in cache_content.splitlines():
|
|
if project_prefix in line:
|
|
if not _is_line_allowed(line):
|
|
return (
|
|
f"source-tree leak in CMake cache: "
|
|
f"{line.strip()[:200]}"
|
|
)
|
|
|
|
# Check compile_commands.json for unexpected project-root paths
|
|
compile_commands = consumer_build_dir / "compile_commands.json"
|
|
if compile_commands.is_file():
|
|
cc_content = compile_commands.read_text(encoding="utf-8", errors="replace")
|
|
for line in cc_content.splitlines():
|
|
if project_prefix in line:
|
|
if not _is_line_allowed(line):
|
|
return (
|
|
f"source-tree leak in compile_commands: "
|
|
f"{line.strip()[:200]}"
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def run_install_consumer(
|
|
project_root: pathlib.Path,
|
|
fixture_path: pathlib.Path,
|
|
*,
|
|
dry_run: bool = False,
|
|
log_dir: pathlib.Path | None = None,
|
|
) -> InstallConsumerResult:
|
|
"""Run a two-phase install-consumer QA test.
|
|
|
|
Phase 1: configure, build, and install the package into a temp prefix.
|
|
Phase 2: configure, build, and run a separate consumer using only
|
|
find_package(CONFIG REQUIRED) against the installed prefix.
|
|
Checks for source-tree path leakage in the consumer build.
|
|
"""
|
|
fixture_id = _case_id(fixture_path)
|
|
pkg_dir = fixture_path / "package"
|
|
consumer_dir = fixture_path / "consumer"
|
|
|
|
if dry_run:
|
|
print(f"DRY-RUN install-consumer-{fixture_id}")
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id,
|
|
status=STATUS_DEFERRED,
|
|
message="dry-run",
|
|
)
|
|
|
|
# Ensure cmake is available
|
|
try:
|
|
_ = common.require_tool("cmake")
|
|
except common.ScriptError as exc:
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id,
|
|
status=STATUS_DEFERRED,
|
|
message=f"tool missing: {exc}",
|
|
)
|
|
|
|
# Set up directories
|
|
build_base = _build_root(project_root) / f"install-consumer-{fixture_id}"
|
|
pkg_build = build_base / "pkg-build"
|
|
consumer_build = build_base / "consumer-build"
|
|
install_prefix = build_base / "install-prefix"
|
|
|
|
# Clean previous runs
|
|
if build_base.is_dir():
|
|
shutil.rmtree(build_base)
|
|
pkg_build.mkdir(parents=True, exist_ok=True)
|
|
consumer_build.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Set up log directory
|
|
actual_log_dir = log_dir
|
|
if actual_log_dir is None:
|
|
actual_log_dir = project_root / ".sisyphus" / "evidence" / "install-consumer" / fixture_id
|
|
actual_log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# --- Phase 1: Configure, build, install package ---
|
|
pkg_configure_cmd = (
|
|
"cmake", "-S", str(pkg_dir), "-B", str(pkg_build),
|
|
f"-DCC_PROJECT_ROOT={project_root}",
|
|
f"-DCMAKE_INSTALL_PREFIX={install_prefix}",
|
|
"-DCMAKE_BUILD_TYPE=Release",
|
|
)
|
|
|
|
print(f"install-consumer-{fixture_id}: phase 1 — configure package")
|
|
pkg_configure = subprocess.run(
|
|
list(pkg_configure_cmd),
|
|
cwd=str(project_root),
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
_log = (
|
|
f"command: {common.format_command(pkg_configure_cmd)}\n"
|
|
f"returncode: {pkg_configure.returncode}\n"
|
|
f"--- stdout ---\n{pkg_configure.stdout}"
|
|
f"--- stderr ---\n{pkg_configure.stderr}"
|
|
)
|
|
_ = (actual_log_dir / "pkg-configure.log").write_text(_log, encoding="utf-8")
|
|
|
|
if pkg_configure.returncode != 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"package configure failed (rc={pkg_configure.returncode})\n"
|
|
f" stderr: {pkg_configure.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
pkg_build_cmd = ("cmake", "--build", str(pkg_build))
|
|
print(f"install-consumer-{fixture_id}: phase 1 — build package")
|
|
pkg_build_result = subprocess.run(
|
|
list(pkg_build_cmd),
|
|
cwd=str(project_root),
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
_log = (
|
|
f"command: {common.format_command(pkg_build_cmd)}\n"
|
|
f"returncode: {pkg_build_result.returncode}\n"
|
|
f"--- stdout ---\n{pkg_build_result.stdout}"
|
|
f"--- stderr ---\n{pkg_build_result.stderr}"
|
|
)
|
|
_ = (actual_log_dir / "pkg-build.log").write_text(_log, encoding="utf-8")
|
|
|
|
if pkg_build_result.returncode != 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"package build failed (rc={pkg_build_result.returncode})\n"
|
|
f" stderr: {pkg_build_result.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
pkg_install_cmd = ("cmake", "--install", str(pkg_build))
|
|
print(f"install-consumer-{fixture_id}: phase 1 — install package")
|
|
pkg_install_result = subprocess.run(
|
|
list(pkg_install_cmd),
|
|
cwd=str(project_root),
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
_log = (
|
|
f"command: {common.format_command(pkg_install_cmd)}\n"
|
|
f"returncode: {pkg_install_result.returncode}\n"
|
|
f"--- stdout ---\n{pkg_install_result.stdout}"
|
|
f"--- stderr ---\n{pkg_install_result.stderr}"
|
|
)
|
|
_ = (actual_log_dir / "pkg-install.log").write_text(_log, encoding="utf-8")
|
|
|
|
if pkg_install_result.returncode != 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"package install failed (rc={pkg_install_result.returncode})\n"
|
|
f" stderr: {pkg_install_result.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
# --- Phase 2: Configure, build, run consumer ---
|
|
consumer_configure_cmd = (
|
|
"cmake", "-S", str(consumer_dir), "-B", str(consumer_build),
|
|
f"-DCMAKE_PREFIX_PATH={install_prefix}",
|
|
"-DCMAKE_BUILD_TYPE=Release",
|
|
)
|
|
|
|
print(f"install-consumer-{fixture_id}: phase 2 — configure consumer")
|
|
consumer_configure = subprocess.run(
|
|
list(consumer_configure_cmd),
|
|
cwd=str(consumer_dir),
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
_log = (
|
|
f"command: {common.format_command(consumer_configure_cmd)}\n"
|
|
f"returncode: {consumer_configure.returncode}\n"
|
|
f"--- stdout ---\n{consumer_configure.stdout}"
|
|
f"--- stderr ---\n{consumer_configure.stderr}"
|
|
)
|
|
_ = (actual_log_dir / "consumer-configure.log").write_text(_log, encoding="utf-8")
|
|
|
|
if consumer_configure.returncode != 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"consumer configure failed (rc={consumer_configure.returncode})\n"
|
|
f" stderr: {consumer_configure.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
# Source-tree leakage detection
|
|
leakage = _check_source_tree_leakage(project_root, consumer_build, install_prefix, consumer_dir)
|
|
if leakage is not None:
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"source-tree leakage detected: {leakage}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
consumer_build_cmd = ("cmake", "--build", str(consumer_build))
|
|
print(f"install-consumer-{fixture_id}: phase 2 — build consumer")
|
|
consumer_build_result = subprocess.run(
|
|
list(consumer_build_cmd),
|
|
cwd=str(consumer_dir),
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
_log = (
|
|
f"command: {common.format_command(consumer_build_cmd)}\n"
|
|
f"returncode: {consumer_build_result.returncode}\n"
|
|
f"--- stdout ---\n{consumer_build_result.stdout}"
|
|
f"--- stderr ---\n{consumer_build_result.stderr}"
|
|
)
|
|
_ = (actual_log_dir / "consumer-build.log").write_text(_log, encoding="utf-8")
|
|
|
|
if consumer_build_result.returncode != 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"consumer build failed (rc={consumer_build_result.returncode})\n"
|
|
f" stderr: {consumer_build_result.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
# Run the consumer executable
|
|
# Find the built executable
|
|
consumer_exe = consumer_build / "consumer_app"
|
|
if not consumer_exe.is_file():
|
|
# Try with common extensions
|
|
for ext in ("", ".exe"):
|
|
candidate = consumer_build / f"consumer_app{ext}"
|
|
if candidate.is_file():
|
|
consumer_exe = candidate
|
|
break
|
|
|
|
if not consumer_exe.is_file():
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"consumer executable not found at {consumer_exe}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
print(f"install-consumer-{fixture_id}: phase 2 — run consumer")
|
|
consumer_run_result = subprocess.run(
|
|
[str(consumer_exe)],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
_log = (
|
|
f"command: {consumer_exe}\n"
|
|
f"returncode: {consumer_run_result.returncode}\n"
|
|
f"--- stdout ---\n{consumer_run_result.stdout}"
|
|
f"--- stderr ---\n{consumer_run_result.stderr}"
|
|
)
|
|
_ = (actual_log_dir / "consumer-run.log").write_text(_log, encoding="utf-8")
|
|
|
|
if consumer_run_result.returncode != 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
|
|
f"consumer run failed (rc={consumer_run_result.returncode})\n"
|
|
f" stdout: {consumer_run_result.stdout[:500]}\n"
|
|
f" stderr: {consumer_run_result.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
# Write summary log
|
|
summary = (
|
|
f"install-consumer-{fixture_id}: PASS\n"
|
|
f"install_prefix: {install_prefix}\n"
|
|
f"source_tree_leakage: none\n"
|
|
f"consumer_output: {consumer_run_result.stdout.strip()}\n"
|
|
)
|
|
_ = (actual_log_dir / "summary.log").write_text(summary, encoding="utf-8")
|
|
|
|
msg = (
|
|
f"{STATUS_PASS}: install-consumer-{fixture_id} "
|
|
"— package installed, consumer built and ran successfully"
|
|
)
|
|
print(msg)
|
|
return InstallConsumerResult(
|
|
fixture_id=fixture_id, status=STATUS_PASS, message=msg,
|
|
log_dir=actual_log_dir,
|
|
)
|
|
|
|
|
|
def run_all_install_consumers(
|
|
project_root: pathlib.Path,
|
|
*,
|
|
dry_run: bool = False,
|
|
log_dir: pathlib.Path | None = None,
|
|
) -> list[InstallConsumerResult]:
|
|
"""Discover and run all install-consumer fixtures. Returns per-fixture results."""
|
|
fixtures = discover_install_consumer_fixtures(project_root)
|
|
results: list[InstallConsumerResult] = []
|
|
for fixture_path in fixtures:
|
|
result = run_install_consumer(
|
|
project_root, fixture_path, dry_run=dry_run, log_dir=log_dir,
|
|
)
|
|
results.append(result)
|
|
return results
|
|
|
|
|
|
def summarize_install_consumer_results(
|
|
results: Sequence[InstallConsumerResult],
|
|
) -> int:
|
|
"""Print summary and return exit code (0 = all pass, 1 = any fail)."""
|
|
passed = sum(1 for r in results if r.status == STATUS_PASS)
|
|
failed = sum(1 for r in results if r.status == STATUS_FAIL)
|
|
deferred = sum(1 for r in results if r.status == STATUS_DEFERRED)
|
|
total = len(results)
|
|
|
|
print(f"\ninstall-consumer summary: {total} fixtures, {passed} passed, {failed} failed, {deferred} deferred")
|
|
|
|
if failed > 0:
|
|
print(f"{STATUS_FAIL}: {failed} install-consumer fixture(s) failed")
|
|
return 1
|
|
|
|
if total == 0:
|
|
print(f"{STATUS_DEFERRED}: no install-consumer fixtures discovered")
|
|
return 2
|
|
|
|
if deferred > 0 and passed == 0:
|
|
print(f"{STATUS_DEFERRED}: {deferred} install-consumer fixture(s) planned (dry-run)")
|
|
return 0
|
|
|
|
if deferred > 0:
|
|
print(f"{STATUS_PASS}: {passed} executed, {deferred} deferred (dry-run)")
|
|
return 0
|
|
|
|
print(f"{STATUS_PASS}: all {total} install-consumer fixture(s) passed")
|
|
return 0
|
|
|
|
def build_cmake_plans(
|
|
project_root: pathlib.Path,
|
|
*,
|
|
include_positive: bool = True,
|
|
include_negative: bool = True,
|
|
include_subproject: bool = True,
|
|
include_install_export: bool = True,
|
|
subset: Sequence[str] | None = None,
|
|
) -> list[common.CommandPlan]:
|
|
"""Build CommandPlan entries for all selected fixtures."""
|
|
build_root = _build_root(project_root)
|
|
|
|
plans: list[common.CommandPlan] = []
|
|
|
|
fixtures: list[tuple[pathlib.Path, str]] = [] # (path, kind)
|
|
|
|
if include_positive:
|
|
for p in discover_positive_fixtures(project_root):
|
|
fixtures.append((p, "positive"))
|
|
if include_subproject:
|
|
for p in discover_subproject_fixtures(project_root):
|
|
fixtures.append((p, "positive"))
|
|
if include_install_export:
|
|
for p in discover_install_export_fixtures(project_root):
|
|
fixtures.append((p, "positive"))
|
|
if include_negative:
|
|
for p in discover_negative_fixtures(project_root):
|
|
fixtures.append((p, "negative"))
|
|
for p in discover_negative_install_export_fixtures(project_root):
|
|
fixtures.append((p, "negative"))
|
|
for p in discover_negative_fuzz_smoke_fixtures(project_root):
|
|
fixtures.append((p, "negative"))
|
|
for fixture_path, _kind in fixtures:
|
|
case_id = _case_id(fixture_path)
|
|
if subset is not None and case_id not in subset:
|
|
continue
|
|
fixture_build = build_root / case_id
|
|
plans.append(
|
|
common.CommandPlan(
|
|
label=f"cmake-helper-fixture-{case_id}",
|
|
command=(
|
|
"cmake",
|
|
"-S",
|
|
str(fixture_path),
|
|
"-B",
|
|
str(fixture_build),
|
|
f"-DCC_PROJECT_ROOT={project_root}",
|
|
),
|
|
cwd=project_root,
|
|
required_tool="cmake",
|
|
)
|
|
)
|
|
|
|
return plans
|
|
|
|
|
|
def run_fixture(
|
|
plan: common.CommandPlan,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> FixtureResult:
|
|
"""Execute a single fixture plan and return a FixtureResult."""
|
|
case_id = plan.label.removeprefix("cmake-helper-fixture-")
|
|
build_root = _build_root(plan.cwd)
|
|
|
|
if dry_run:
|
|
print(f"DRY-RUN {plan.label}: {common.format_command(plan.command)}")
|
|
return FixtureResult(
|
|
fixture_id=case_id,
|
|
status=STATUS_DEFERRED,
|
|
message="dry-run",
|
|
)
|
|
|
|
# Determine if this is a negative fixture by looking up the diagnostics table.
|
|
expected_diag = NEGATIVE_DIAGNOSTICS.get(case_id)
|
|
is_negative = expected_diag is not None
|
|
|
|
# Ensure build dir exists.
|
|
fixture_build = build_root / case_id
|
|
fixture_build.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Check for cmake tool.
|
|
if plan.required_tool is not None:
|
|
try:
|
|
_ = common.require_tool(plan.required_tool)
|
|
except common.ScriptError as exc:
|
|
return FixtureResult(
|
|
fixture_id=case_id,
|
|
status=STATUS_DEFERRED,
|
|
message=f"tool missing: {exc}",
|
|
)
|
|
|
|
completed = subprocess.run(
|
|
list(plan.command),
|
|
cwd=str(plan.cwd),
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
if is_negative:
|
|
# Negative: cmake configure should FAIL, and stderr must contain the
|
|
# expected diagnostic substring.
|
|
if completed.returncode == 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: negative fixture {case_id} "
|
|
"expected configure failure but cmake returned 0"
|
|
)
|
|
print(msg)
|
|
return FixtureResult(fixture_id=case_id, status=STATUS_FAIL, message=msg)
|
|
|
|
combined_output = re.sub(r'\s+', ' ', completed.stderr + completed.stdout)
|
|
if expected_diag not in combined_output:
|
|
msg = (
|
|
f"{STATUS_FAIL}: negative fixture {case_id} "
|
|
f"configure failed as expected but diagnostic substring "
|
|
f"not found.\n expected: {expected_diag!r}\n stderr: {completed.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return FixtureResult(fixture_id=case_id, status=STATUS_FAIL, message=msg)
|
|
|
|
msg = f"{STATUS_PASS}: negative fixture {case_id} — configure failed with expected diagnostic"
|
|
print(msg)
|
|
return FixtureResult(fixture_id=case_id, status=STATUS_PASS, message=msg)
|
|
|
|
# Positive: cmake configure should SUCCEED.
|
|
if completed.returncode != 0:
|
|
msg = (
|
|
f"{STATUS_FAIL}: positive fixture {case_id} "
|
|
f"configure failed (rc={completed.returncode})\n stderr: {completed.stderr[:500]}"
|
|
)
|
|
print(msg)
|
|
return FixtureResult(fixture_id=case_id, status=STATUS_FAIL, message=msg)
|
|
|
|
msg = f"{STATUS_PASS}: positive fixture {case_id} — configure succeeded"
|
|
print(msg)
|
|
return FixtureResult(fixture_id=case_id, status=STATUS_PASS, message=msg)
|
|
|
|
|
|
def run_all_fixtures(
|
|
project_root: pathlib.Path,
|
|
*,
|
|
dry_run: bool = False,
|
|
subset: Sequence[str] | None = None,
|
|
include_positive: bool = True,
|
|
include_negative: bool = True,
|
|
include_subproject: bool = True,
|
|
include_install_export: bool = True,
|
|
) -> list[FixtureResult]:
|
|
"""Discover, plan, and run all selected fixtures. Returns per-fixture results."""
|
|
plans = build_cmake_plans(
|
|
project_root,
|
|
include_positive=include_positive,
|
|
include_negative=include_negative,
|
|
include_subproject=include_subproject,
|
|
include_install_export=include_install_export,
|
|
subset=subset,
|
|
)
|
|
results: list[FixtureResult] = []
|
|
for plan in plans:
|
|
result = run_fixture(plan, dry_run=dry_run)
|
|
results.append(result)
|
|
|
|
return results
|
|
|
|
|
|
def summarize_results(results: Sequence[FixtureResult]) -> int:
|
|
"""Print summary and return exit code (0 = all pass, 1 = any fail)."""
|
|
passed = sum(1 for r in results if r.status == STATUS_PASS)
|
|
failed = sum(1 for r in results if r.status == STATUS_FAIL)
|
|
deferred = sum(1 for r in results if r.status == STATUS_DEFERRED)
|
|
total = len(results)
|
|
|
|
print(f"\ncmake-helper-fixtures summary: {total} fixtures, {passed} passed, {failed} failed, {deferred} deferred")
|
|
|
|
if failed > 0:
|
|
print(f"{STATUS_FAIL}: {failed} fixture(s) failed")
|
|
return 1
|
|
|
|
if total == 0:
|
|
print(f"{STATUS_DEFERRED}: no fixtures discovered")
|
|
return 2
|
|
|
|
if deferred > 0 and passed == 0:
|
|
print(f"{STATUS_DEFERRED}: {deferred} fixture(s) planned (dry-run)")
|
|
return 0
|
|
|
|
if deferred > 0:
|
|
print(f"{STATUS_PASS}: {passed} executed, {deferred} deferred (dry-run)")
|
|
return 0
|
|
|
|
print(f"{STATUS_PASS}: all {total} fixture(s) passed")
|
|
return 0
|