Files
Jakub Piasecki 3b634bc520 Run the snapshot comparison on CI (#56207)
Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/56207

Changelog: [Internal]

Reviewed By: cipolleschi

Differential Revision: D96101927

fbshipit-source-id: 06c6dd11c145be44bcdd97f14683a222c66c96c6
2026-03-31 10:10:04 -07:00

363 lines
11 KiB
Python

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""
Entry point for running the parser package as a script.
Usage:
# With output directory:
python ... --output-dir /path/to/output
"""
import argparse
import concurrent.futures
import os
import shutil
import subprocess
import sys
import tempfile
import traceback
from .config import ApiViewSnapshotConfig, parse_config_file
from .doxygen import get_doxygen_bin, run_doxygen
from .main import build_snapshot
from .path_utils import get_react_native_dir
from .snapshot_diff import validate_snapshots
def run_command(
cmd: list[str],
label: str,
verbose: bool = False,
**kwargs,
) -> subprocess.CompletedProcess:
"""Run a subprocess command with consistent error handling."""
result = subprocess.run(cmd, **kwargs)
if result.returncode != 0:
stderr_output = result.stderr or ""
if isinstance(stderr_output, bytes):
stderr_output = stderr_output.decode("utf-8", errors="replace")
print(f"{label} failed (exit code {result.returncode})", file=sys.stderr)
if stderr_output:
print(stderr_output, file=sys.stderr)
raise RuntimeError(
f"{label} finished with error (exit code {result.returncode})"
)
elif verbose:
print(f"{label} finished successfully")
return result
def build_codegen(
platform: str,
verbose: bool = False,
output_path: str = "./api/codegen",
label: str = "",
) -> str:
react_native_dir = os.path.join(get_react_native_dir(), "packages", "react-native")
node_bin = os.environ.get("NODE_BIN", "node")
run_command(
[
node_bin,
"./scripts/generate-codegen-artifacts.js",
"--path",
"./",
"--outputPath",
output_path,
"--targetPlatform",
platform,
"--forceOutputPath",
],
label=f"[{label}] Codegen" if label else "Codegen",
verbose=verbose,
cwd=react_native_dir,
capture_output=True,
text=True,
)
if os.path.isabs(output_path):
return output_path
return os.path.join(react_native_dir, output_path)
def build_snapshot_for_view(
api_view: str,
react_native_dir: str,
include_directories: list[str],
exclude_patterns: list[str],
definitions: dict[str, str | int],
output_dir: str,
codegen_dir: str | None = None,
verbose: bool = True,
input_filter: str = None,
work_dir: str | None = None,
exclude_symbols: list[str] | None = None,
) -> str:
if verbose:
print(f"[{api_view}] Generating API view")
include_directories = list(include_directories)
if work_dir is None:
work_dir = os.path.join(react_native_dir, "api")
if codegen_dir is not None:
include_directories.append(codegen_dir)
elif verbose:
print(f"[{api_view}] Skipping codegen")
config_file = f".doxygen.config.{api_view}.generated"
run_doxygen(
working_dir=react_native_dir,
include_directories=include_directories,
exclude_patterns=exclude_patterns,
definitions=definitions,
input_filter=input_filter,
verbose=verbose,
output_dir=work_dir,
config_file=config_file,
label=api_view,
)
if verbose:
print(f"[{api_view}] Building snapshot")
snapshot = build_snapshot(
os.path.join(work_dir, "xml"), exclude_symbols=exclude_symbols
)
snapshot_string = snapshot.to_string()
output_file = os.path.join(output_dir, f"{api_view}Cxx.api")
os.makedirs(output_dir, exist_ok=True)
with open(output_file, "w") as f:
f.write("// @" + "generated by scripts/cxx-api\n\n")
f.write(snapshot_string)
return snapshot_string
def build_snapshots(
snapshot_configs: list[ApiViewSnapshotConfig],
react_native_dir: str,
output_dir: str,
input_filter: str | None,
verbose: bool,
view_filter: str | None = None,
is_test: bool = False,
keep_xml: bool = False,
) -> None:
if not is_test:
configs_to_build = [
config
for config in snapshot_configs
if not view_filter or config.snapshot_name == view_filter
]
with tempfile.TemporaryDirectory(prefix="cxx-api-") as parent_tmp:
# Run codegen once per unique platform before parallel snapshot generation.
# Debug/release variants share the same codegen output, and running
# multiple codegen processes in parallel causes race conditions
# (e.g. concurrent yarn install in buildCodegenIfNeeded).
codegen_dirs: dict[str, str] = {}
for config in configs_to_build:
platform = config.codegen_platform
if platform is not None and platform not in codegen_dirs:
codegen_output = os.path.join(parent_tmp, f"codegen-{platform}")
os.makedirs(codegen_output, exist_ok=True)
codegen_dirs[platform] = build_codegen(
platform,
verbose=verbose,
output_path=codegen_output,
label=platform,
)
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {}
for config in configs_to_build:
work_dir = os.path.join(parent_tmp, config.snapshot_name)
os.makedirs(work_dir, exist_ok=True)
future = executor.submit(
build_snapshot_for_view,
api_view=config.snapshot_name,
react_native_dir=react_native_dir,
include_directories=config.inputs,
exclude_patterns=config.exclude_patterns,
definitions=config.definitions,
output_dir=output_dir,
codegen_dir=codegen_dirs.get(config.codegen_platform),
verbose=verbose,
input_filter=input_filter if config.input_filter else None,
work_dir=work_dir,
exclude_symbols=config.exclude_symbols,
)
futures[future] = config.snapshot_name
errors = []
for future in concurrent.futures.as_completed(futures):
view_name = futures[future]
try:
future.result()
except Exception as e:
errors.append((view_name, e))
if verbose:
print(
f"[{view_name}] Error generating:\n"
f"{traceback.format_exc()}"
)
if errors:
failed_views = ", ".join(name for name, _ in errors)
raise RuntimeError(f"Failed to generate snapshots: {failed_views}")
else:
work_dir = os.path.join(react_native_dir, "api")
snapshot = build_snapshot_for_view(
api_view="Test",
react_native_dir=react_native_dir,
include_directories=[],
exclude_patterns=[],
definitions={},
output_dir=output_dir,
codegen_dir=None,
verbose=verbose,
input_filter=input_filter,
work_dir=work_dir,
)
if keep_xml:
xml_src = os.path.join(work_dir, "xml")
xml_dst = os.path.join(output_dir, "xml")
if os.path.exists(xml_dst):
shutil.rmtree(xml_dst)
shutil.copytree(xml_src, xml_dst)
if verbose:
print(f"XML files saved to {xml_dst}")
if verbose:
print(snapshot)
def get_default_snapshot_dir() -> str:
return os.path.join(get_react_native_dir(), "scripts", "cxx-api", "api-snapshots")
def main():
parser = argparse.ArgumentParser(
description="Generate API snapshots from C++ headers"
)
parser.add_argument(
"--output-dir",
type=str,
help="Output directory for the snapshot",
)
parser.add_argument(
"--validate",
action="store_true",
help="Generate snapshots to a temp directory and compare against committed ones",
)
parser.add_argument(
"--validate-output",
type=str,
help="File path to write validate results to (used with --validate)",
)
parser.add_argument(
"--snapshot-dir",
type=str,
help="Directory containing committed snapshots for comparison (used with --validate)",
)
parser.add_argument(
"--view",
type=str,
help="Name of the API view to generate",
)
parser.add_argument(
"--test",
action="store_true",
help="Run on the local test directory instead of the react-native directory",
)
parser.add_argument(
"--xml",
action="store_true",
help="Keep the generated Doxygen XML files next to the .api output in a xml/ directory",
)
args = parser.parse_args()
verbose = not args.validate
doxygen_bin = get_doxygen_bin()
version_result = subprocess.run(
[doxygen_bin, "--version"],
capture_output=True,
text=True,
)
if verbose:
print(f"Using Doxygen {version_result.stdout.strip()} ({doxygen_bin})")
react_native_package_dir = (
os.path.join(get_react_native_dir(), "packages", "react-native")
if not args.test
else os.path.join(get_react_native_dir(), "scripts", "cxx-api", "manual_test")
)
if verbose:
print(f"Running in directory: {react_native_package_dir}")
input_filter_path = os.path.join(
get_react_native_dir(),
"scripts",
"cxx-api",
"parser",
"input_filters",
"main.py",
)
input_filter = None
if os.path.exists(input_filter_path):
input_filter = f"python3 {input_filter_path}"
config_path = os.path.join(
get_react_native_dir(), "scripts", "cxx-api", "config.yml"
)
snapshot_configs = parse_config_file(
config_path,
get_react_native_dir(),
)
with tempfile.TemporaryDirectory() as tmpdir:
snapshot_output_dir = (
args.output_dir or tmpdir
if args.validate
else args.output_dir or get_default_snapshot_dir()
)
build_snapshots(
output_dir=snapshot_output_dir,
verbose=not args.validate,
snapshot_configs=snapshot_configs,
react_native_dir=react_native_package_dir,
input_filter=input_filter,
view_filter=args.view,
is_test=args.test,
keep_xml=args.xml,
)
if args.validate:
snapshot_dir = args.snapshot_dir or get_default_snapshot_dir()
if not validate_snapshots(
snapshot_output_dir,
snapshot_dir,
output_file=args.validate_output,
):
sys.exit(1)
print("All snapshot validations passed")
if __name__ == "__main__":
main()