| #!/usr/bin/env python3 | |
| # Copyright 2026 The Chromium Authors | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Tool to check if a test suite is barred by ci_only = True configuration.""" | |
| import argparse | |
| import json | |
| import pathlib | |
| import subprocess | |
| import sys | |
| from typing import Any, Callable, NamedTuple | |
| class TrybotEntry(NamedTuple): | |
| """Holds trybot name and target group name. | |
| Attributes: | |
| trybot: Name of the trybot. | |
| group: Target group name (e.g. chromium.coverage). | |
| """ | |
| trybot: str | |
| group: str | |
| class BuilderConfig(NamedTuple): | |
| """Holds builder name and its ci_only status. | |
| Attributes: | |
| builder: Name of the builder. | |
| ci_only: Boolean status indicating if the builder is ci_only. | |
| """ | |
| builder: str | |
| ci_only: bool | |
| def discover_trybots_for_test_suite( | |
| src_root: pathlib.Path, | |
| test_suite: str, | |
| revision: str | None = None, | |
| ) -> list[TrybotEntry]: | |
| """Discovers all trybots and target groups configuring the test suite. | |
| Args: | |
| src_root: The root directory of the chromium source checkout. | |
| test_suite: Name of the test suite to search for. | |
| revision: Optional git revision (commit hash/branch) to search at. | |
| Returns: | |
| A list of TrybotEntry NamedTuples. | |
| """ | |
| # Match exact JSON pattern for the test suite name. | |
| pattern = f'\\"name\\": \\"{test_suite}\\"' | |
| cmd = [ | |
| 'git', | |
| 'grep', | |
| '-l', | |
| pattern, | |
| ] | |
| if revision: | |
| cmd.append(revision) | |
| cmd.extend(['--', 'infra/config/generated/builders/try/*/targets/*.json']) | |
| result = subprocess.run( | |
| cmd, cwd=src_root, capture_output=True, text=True, check=False | |
| ) | |
| if result.returncode != 0: | |
| return [] | |
| trybots_and_groups = [] | |
| for line in result.stdout.splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if revision and ':' in line: | |
| line = line.split(':', 1)[1] | |
| path = pathlib.Path(line) | |
| parts = path.parts | |
| trybot = parts[5] | |
| group = path.stem | |
| trybots_and_groups.append(TrybotEntry(trybot=trybot, group=group)) | |
| return trybots_and_groups | |
| def read_local(src_root: pathlib.Path, rel_path: str) -> str | None: | |
| """Reads a configuration file from the local disk. | |
| Args: | |
| src_root: The root directory of the chromium source checkout. | |
| rel_path: Relative path to the file from the source root. | |
| Returns: | |
| The file contents as a string, or None if the file was missing/unread. | |
| """ | |
| file_path = src_root / rel_path | |
| if not file_path.exists(): | |
| return None | |
| try: | |
| return file_path.read_text(encoding='utf-8') | |
| except OSError as e: | |
| print(f'Error reading local file {file_path}: {e}', file=sys.stderr) | |
| return None | |
| def read_git( | |
| src_root: pathlib.Path, revision: str, rel_path: str | |
| ) -> str | None: | |
| """Reads a configuration file from a git revision. | |
| Args: | |
| src_root: The root directory of the chromium source checkout. | |
| revision: The git revision (commit hash/branch) to query. | |
| rel_path: Relative path to the file from the source root. | |
| Returns: | |
| The file contents as a string, or None if the git command failed. | |
| """ | |
| cmd = ['git', 'show', f'{revision}:{rel_path}'] | |
| result = subprocess.run( | |
| cmd, cwd=src_root, capture_output=True, text=True, check=False | |
| ) | |
| if result.returncode != 0: | |
| return None | |
| return result.stdout | |
| def check_coverage_enabled( | |
| read_fn: Callable[[str], str | None], trybot: str | |
| ) -> bool: | |
| """Checks if coverage is enabled in the trybot's GN args. | |
| Args: | |
| read_fn: Callable that takes a relative path and returns file content. | |
| trybot: Name of the trybot to inspect. | |
| Returns: | |
| True if code coverage is enabled for Clang or JaCoCo, False otherwise. | |
| """ | |
| gn_args_path = f'infra/config/generated/builders/try/{trybot}/gn-args.json' | |
| gn_args_content = read_fn(gn_args_path) | |
| if not gn_args_content: | |
| return False | |
| try: | |
| gn_args = json.loads(gn_args_content).get('gn_args', {}) | |
| return bool( | |
| gn_args.get('use_clang_coverage', False) | |
| or gn_args.get('use_jacoco_coverage', False) | |
| ) | |
| except json.JSONDecodeError: | |
| return False | |
| def check_trybot_coverage_config( | |
| read_fn: Callable[[str], str | None], | |
| trybot: str, | |
| group: str, | |
| test_suite: str, | |
| ) -> list[BuilderConfig]: | |
| """Checks target specs for a trybot to see if a test suite is ci_only. | |
| Args: | |
| read_fn: Callable that takes a relative path and returns file content. | |
| trybot: Name of the trybot to check. | |
| group: Target group name (e.g. chromium.coverage). | |
| test_suite: Name of the test suite to verify. | |
| Returns: | |
| A list of BuilderConfig NamedTuples. | |
| """ | |
| target_spec_rel_path = ( | |
| f'infra/config/generated/builders/try/{trybot}/targets/{group}.json' | |
| ) | |
| target_spec_content = read_fn(target_spec_rel_path) | |
| if not target_spec_content: | |
| return [] | |
| try: | |
| target_spec = json.loads(target_spec_content) | |
| except json.JSONDecodeError as e: | |
| print( | |
| f'Error parsing target spec {target_spec_rel_path}: {e}', | |
| file=sys.stderr, | |
| ) | |
| return [] | |
| results = [] | |
| for builder_name, builder_specs in target_spec.items(): | |
| suite_found = False | |
| is_ci_only = False | |
| for test_type in [ | |
| 'gtest_tests', | |
| 'isolated_scripts', | |
| 'junit_tests', | |
| 'scripts', | |
| ]: | |
| tests = builder_specs.get(test_type, []) | |
| for test in tests: | |
| if test.get('name') == test_suite: | |
| suite_found = True | |
| is_ci_only = test.get('ci_only', False) | |
| break | |
| if suite_found: | |
| break | |
| if suite_found: | |
| results.append(BuilderConfig(builder=builder_name, ci_only=is_ci_only)) | |
| return results | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| 'Check if a test suite is barred by ci_only = True on trybots.' | |
| ) | |
| ) | |
| parser.add_argument( | |
| 'test_suite', | |
| help='Name of the test suite (e.g. chrome_junit_tests)', | |
| ) | |
| parser.add_argument( | |
| '--revision', | |
| '-r', | |
| help=( | |
| 'Git revision to check the configuration at (e.g., commit hash, branch).' | |
| ), | |
| ) | |
| args = parser.parse_args() | |
| script_dir = pathlib.Path(__file__).resolve().parent | |
| src_root = script_dir.parents[1] | |
| if not src_root.exists(): | |
| print(f'Error: Src path {src_root} does not exist', file=sys.stderr) | |
| sys.exit(1) | |
| if args.revision: | |
| # Check if the revision is valid | |
| res = subprocess.run( | |
| ['git', 'rev-parse', '--verify', args.revision], | |
| cwd=src_root, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| if res.returncode != 0: | |
| print(f'Error: Invalid revision: {args.revision}', file=sys.stderr) | |
| sys.exit(1) | |
| trybots_and_groups = discover_trybots_for_test_suite( | |
| src_root, args.test_suite, args.revision | |
| ) | |
| if not trybots_and_groups: | |
| print( | |
| f"Warning: Test suite '{args.test_suite}' was not found on any trybots.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(0) | |
| if args.revision: | |
| read_fn = lambda path: read_git(src_root, args.revision, path) | |
| else: | |
| read_fn = lambda path: read_local(src_root, path) | |
| revision_msg = f' at revision {args.revision}' if args.revision else '' | |
| print( | |
| f"Checking test suite '{args.test_suite}' across" | |
| f' {len(trybots_and_groups)} configured trybots{revision_msg}...' | |
| ) | |
| for entry in sorted(trybots_and_groups, key=lambda e: e.trybot): | |
| trybot = entry.trybot | |
| group = entry.group | |
| configs = check_trybot_coverage_config( | |
| read_fn, trybot, group, args.test_suite | |
| ) | |
| coverage_enabled = check_coverage_enabled(read_fn, trybot) | |
| for config in configs: | |
| status = 'ci_only = True' if config.ci_only else 'ci_only = False' | |
| coverage_status = ( | |
| 'Coverage: Enabled' if coverage_enabled else 'Coverage: Disabled' | |
| ) | |
| print( | |
| f' Trybot: {trybot:<30} | Builder: {group}:{config.builder:<30}' | |
| f' | {status:<15} | {coverage_status}' | |
| ) | |
| if __name__ == '__main__': | |
| main() |