| # Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| import argparse | |
| import asyncio | |
| import collections | |
| import dataclasses | |
| import json | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import typing | |
| import build_dir | |
| import fx_cmd | |
| import statusinfo | |
| HTTP_PORT = 6240 | |
| @dataclasses.dataclass | |
| class StatsData: | |
| categories: dict[str, dict[str, int]] = dataclasses.field( | |
| default_factory=dict | |
| ) | |
| tags: dict[str, int] = dataclasses.field(default_factory=dict) | |
| class Options(argparse.Namespace): | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.web: bool = False | |
| self.stats: bool = False | |
| self.paths: list[str] = [] | |
| self.port: int = HTTP_PORT | |
| async def main(arg_override: list[str] | None = None) -> None: | |
| parser = argparse.ArgumentParser(description="Manage test categories") | |
| parser.add_argument( | |
| "paths", | |
| nargs="*", | |
| help="Paths to query or show stats for", | |
| ) | |
| parser.add_argument( | |
| "--stats", | |
| action="store_true", | |
| help="Output stats instead of details", | |
| ) | |
| parser.add_argument( | |
| "--web", | |
| action="store_true", | |
| help="Run the web server to visualize test categories", | |
| ) | |
| parser.add_argument( | |
| "-p", | |
| "--port", | |
| type=int, | |
| default=HTTP_PORT, | |
| help=f"Port to run the web server on (default: {HTTP_PORT})", | |
| ) | |
| args = parser.parse_args(args=arg_override, namespace=Options()) | |
| fuchsia_dir = os.environ.get("FUCHSIA_DIR") | |
| if not fuchsia_dir: | |
| print("FUCHSIA_DIR environment variable not set.") | |
| sys.exit(1) | |
| out_dir = os.path.join(fuchsia_dir, "out", "test-categories-only") | |
| # Check if metadata exists in current build directory | |
| current_build_dir = build_dir.get_build_directory() | |
| metadata_path = os.path.join(current_build_dir, "testing_metadata.json") | |
| if os.path.exists(metadata_path): | |
| important(f"Using existing metadata from {metadata_path}") | |
| else: | |
| metadata_path = os.path.join(out_dir, "testing_metadata.json") | |
| important(f"Metadata not found in build dir. Generating in {out_dir}") | |
| await ensure_metadata(out_dir) | |
| metadata = load_metadata_from_path(metadata_path) | |
| if args.web: | |
| await run_web_server(fuchsia_dir, metadata_path, args.port) | |
| elif args.stats: | |
| show_stats(metadata, args.paths, fuchsia_dir=fuchsia_dir) | |
| else: | |
| show_details(metadata, args.paths, fuchsia_dir=fuchsia_dir) | |
| def important(text: str) -> None: | |
| print(statusinfo.highlight(text)) | |
| def green(text: str) -> None: | |
| print(statusinfo.green_highlight(text)) | |
| def dim(text: str) -> None: | |
| print(statusinfo.dim(text)) | |
| async def ensure_metadata(out_dir: str) -> None: | |
| fx = fx_cmd.FxCmd(build_directory=out_dir) | |
| set_cmd = [ | |
| "set", | |
| "--no-change-env", | |
| "minimal.x64", | |
| "--with", | |
| "//tools/testing_metadata", | |
| ] | |
| important(f"Running: fx --dir {out_dir} " + " ".join(set_cmd)) | |
| def output_callback(label: str, event: typing.Any) -> None: | |
| print( | |
| f"[{label}] {event.text.decode('utf-8', errors='replace')}", | |
| end="", | |
| file=sys.stderr, | |
| ) | |
| await fx.sync( | |
| *set_cmd, | |
| stdout_callback=lambda e: output_callback("set", e), | |
| stderr_callback=lambda e: output_callback("set", e), | |
| ) | |
| build_cmd = ["build", "//tools/testing_metadata"] | |
| important(f"Running: fx --dir {out_dir} " + " ".join(build_cmd)) | |
| await fx.sync( | |
| *build_cmd, | |
| stdout_callback=lambda e: output_callback("build", e), | |
| stderr_callback=lambda e: output_callback("build", e), | |
| ) | |
| def load_metadata_from_path(path: str) -> dict[str, typing.Any]: | |
| if not os.path.exists(path): | |
| print(f"Error: Metadata file not found at {path}") | |
| sys.exit(1) | |
| with open(path, "r") as f: | |
| return json.load(f) | |
| def show_details( | |
| data: dict[str, typing.Any], paths: list[str], fuchsia_dir: str | |
| ) -> None: | |
| metadata = data.get("metadata", {}) | |
| paths = [p.rstrip("/") for p in paths] | |
| if not paths: | |
| # If no paths listed, show current directory only. | |
| paths = [os.path.relpath(os.getcwd(), fuchsia_dir)] | |
| if paths == ["."]: | |
| paths = [""] | |
| for path in paths: | |
| info = metadata.get(path) | |
| if info: | |
| print(f"Path: {path}") | |
| print(json.dumps(info, indent=2)) | |
| else: | |
| print(f"Path: {path} (No metadata found)") | |
| def calculate_stats( | |
| data: dict[str, typing.Any], paths: list[str], fuchsia_dir: str | |
| ) -> StatsData: | |
| metadata = data.get("metadata", {}) | |
| paths = [p.rstrip("/") for p in paths] | |
| # Filter metadata by paths | |
| filtered_metadata: dict[str, typing.Any] = {} | |
| if not paths: | |
| # If no paths listed, show current directory only. | |
| paths = [os.path.relpath(os.getcwd(), fuchsia_dir)] | |
| if paths == ["."]: | |
| filtered_metadata = metadata | |
| else: | |
| for p in paths: | |
| for k, v in metadata.items(): | |
| if k.startswith(p): | |
| filtered_metadata[k] = v | |
| cat_subcat: dict[str, collections.Counter[str]] = collections.defaultdict( | |
| collections.Counter | |
| ) | |
| tags: collections.Counter[str] = collections.Counter() | |
| for k, v in filtered_metadata.items(): | |
| coverage = v.get("coverage", {}) | |
| cat = coverage.get("category") | |
| subcat = coverage.get("subcategory") or "None" | |
| tgs = coverage.get("tags", []) | |
| if cat: | |
| cat_subcat[cat][subcat] += 1 | |
| for t in tgs: | |
| tags[t] += 1 | |
| return StatsData( | |
| categories={cat: dict(subcats) for cat, subcats in cat_subcat.items()}, | |
| tags=dict(tags), | |
| ) | |
| def show_stats( | |
| data: dict[str, typing.Any], paths: list[str], fuchsia_dir: str | |
| ) -> None: | |
| stats = calculate_stats(data, paths, fuchsia_dir) | |
| print("Categories and Subcategories:") | |
| categories = stats.categories | |
| sorted_cats = sorted( | |
| categories.items(), key=lambda x: sum(x[1].values()), reverse=True | |
| ) | |
| for cat, subcats in sorted_cats: | |
| total = sum(subcats.values()) | |
| print(f" {cat}: {total}") | |
| sorted_subcats = sorted( | |
| subcats.items(), key=lambda x: x[1], reverse=True | |
| ) | |
| for subcat, count in sorted_subcats: | |
| print(f" {subcat}: {count}") | |
| print("\nTags:") | |
| tags = stats.tags | |
| sorted_tags = sorted(tags.items(), key=lambda x: x[1], reverse=True) | |
| for k, v in sorted_tags: | |
| print(f" {k}: {v}") | |
| async def run_web_server( | |
| fuchsia_dir: str, metadata_path: str, port: int | |
| ) -> None: | |
| tmpdir = tempfile.mkdtemp() | |
| try: | |
| shutil.copy(metadata_path, os.path.join(tmpdir, "metadata.json")) | |
| shutil.copy( | |
| os.path.join( | |
| fuchsia_dir, "tools/testing_metadata/visualization/index.html" | |
| ), | |
| os.path.join(tmpdir, "index.html"), | |
| ) | |
| server_process = subprocess.Popen( | |
| [ | |
| sys.executable, | |
| "-m", | |
| "http.server", | |
| "-b", | |
| "127.0.0.1", | |
| str(port), | |
| ], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| cwd=tmpdir, | |
| ) | |
| green(f"View categories at http://localhost:{port}?files=metadata.json") | |
| dim( | |
| "Not seeing the categories you expect? Run `touch BUILD.gn` and then `fx build`" | |
| ) | |
| print("") | |
| print("Press enter to stop the server.") | |
| await asyncio.to_thread(input) | |
| server_process.terminate() | |
| server_process.wait() | |
| finally: | |
| shutil.rmtree(tmpdir) |