From 019c1966d6a6588bd9a7bdaea51a7c493cbe0770 Mon Sep 17 00:00:00 2001 From: Lakshya Kapoor <4314581+kapoorlakshya@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:05:37 -0800 Subject: [PATCH] Add xctrunner and xctrunnertool --- apple/BUILD | 6 + apple/internal/BUILD | 12 ++ apple/internal/xctrunner.bzl | 193 ++++++++++++++++++ apple/xctrunner.bzl | 25 +++ doc/BUILD.bazel | 1 + doc/README.md | 4 + doc/rules-xctrunner.md | 46 +++++ examples/ios/HelloWorldSwift/BUILD | 7 + test/starlark_tests/BUILD | 3 + .../targets_under_test/ios/BUILD | 19 ++ test/starlark_tests/xctrunner_tests.bzl | 66 ++++++ tools/wrapper_common/BUILD | 1 + tools/xctrunnertool/BUILD.bazel | 14 ++ tools/xctrunnertool/lib/dependencies.py | 24 +++ tools/xctrunnertool/lib/lipo_util.py | 37 ++++ tools/xctrunnertool/lib/logger.py | 57 ++++++ tools/xctrunnertool/lib/model.py | 60 ++++++ tools/xctrunnertool/lib/shell.py | 26 +++ tools/xctrunnertool/run.py | 190 +++++++++++++++++ 19 files changed, 791 insertions(+) create mode 100644 apple/internal/xctrunner.bzl create mode 100644 apple/xctrunner.bzl create mode 100644 doc/rules-xctrunner.md create mode 100644 test/starlark_tests/xctrunner_tests.bzl create mode 100644 tools/xctrunnertool/BUILD.bazel create mode 100644 tools/xctrunnertool/lib/dependencies.py create mode 100644 tools/xctrunnertool/lib/lipo_util.py create mode 100644 tools/xctrunnertool/lib/logger.py create mode 100644 tools/xctrunnertool/lib/model.py create mode 100644 tools/xctrunnertool/lib/shell.py create mode 100755 tools/xctrunnertool/run.py diff --git a/apple/BUILD b/apple/BUILD index 445c6268f1..e0309df17f 100644 --- a/apple/BUILD +++ b/apple/BUILD @@ -260,6 +260,12 @@ bzl_library( deps = ["//apple/internal:xcarchive"], ) +bzl_library( + name = "xctrunner", + srcs = ["xctrunner.bzl"], + deps = ["//apple/internal:xctrunner"], +) + bzl_library( name = "docc", srcs = ["docc.bzl"], diff --git a/apple/internal/BUILD b/apple/internal/BUILD index 6afc7b20fa..e0ea3e7b58 100644 --- a/apple/internal/BUILD +++ b/apple/internal/BUILD @@ -816,6 +816,18 @@ bzl_library( ], ) +bzl_library( + name = "xctrunner", + srcs = ["xctrunner.bzl"], + visibility = [ + "//apple:__subpackages__", + ], + deps = [ + "//apple:providers", + "//apple/internal/providers:apple_debug_info", + ], +) + bzl_library( name = "docc", srcs = ["docc.bzl"], diff --git a/apple/internal/xctrunner.bzl b/apple/internal/xctrunner.bzl new file mode 100644 index 0000000000..1da628fa8b --- /dev/null +++ b/apple/internal/xctrunner.bzl @@ -0,0 +1,193 @@ +# Copyright 2025 The Bazel Authors. All rights reserved. +# +# 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. + +""" +Rule for creating a XCTRunner.app with one or more .xctest bundles. Retains same +platform and architectures as the given `tests` bundles. +""" + +load( + "//apple:providers.bzl", + "AppleBundleInfo", +) +load( + "//apple/internal:providers.bzl", + "new_applebundleinfo", +) + +_TestBundleInfo = provider( + "Test bundle info for tests that will be run.", + fields = { + "platform_type": "The platform to bundle for.", + "infoplists": "A `depset` of `File`s of `Info.plist` files.", + "xctests": "A `depset` of paths of XCTest bundles.", + }, +) + +PLATFORM_MAP = { + "ios": "iPhoneOS.platform", + "macos": "MacOSX.platform", + "tvos": "AppleTVOS.platform", + "watchos": "WatchOS.platform", + "visionos": "VisionOS.platform", +} + +def _test_bundle_info_aspect_impl(target, ctx): + rule_attr = ctx.rule.attr + + if AppleBundleInfo in target: + info = target[AppleBundleInfo] + xctests = depset([info.archive]) + infoplists = depset([info.infoplist]) + platform_type = target[AppleBundleInfo].platform_type + else: + deps = getattr(rule_attr, "tests", []) + xctests = depset( + transitive = [ + dep[_TestBundleInfo].xctests + for dep in deps + ], + ) + infoplists = depset( + transitive = [ + dep[_TestBundleInfo].infoplists + for dep in deps + ], + ) + platform_types = [ + dep[AppleBundleInfo].platform_type + for dep in deps + ] + + # Ensure all test bundles are for the same platform + for type in platform_types: + if type != platform_types[0]: + ctx.attr.test_bundle_info_aspect.error( + "All test bundles must be for the same platform: %s" % platform_types, + ) + platform_type = platform_types[0] # Pick one, all are same + + return [ + _TestBundleInfo( + infoplists = infoplists, + xctests = xctests, + platform_type = platform_type, + ), + ] + +test_bundle_info_aspect = aspect( + attr_aspects = ["tests"], + implementation = _test_bundle_info_aspect_impl, +) + +def _xctrunner_impl(ctx): + output = ctx.actions.declare_directory(ctx.attr.name + ".app") + infos = [target[_TestBundleInfo] for target in ctx.attr.tests] + infoplists = depset( + transitive = [info.infoplists for info in infos], + ) + xctests = depset( + transitive = [info.xctests for info in infos], + ) + platform = infos[0].platform_type # Pick one, all should be same + + # Args for `_xctrunnertool` + args = ctx.actions.args() + args.add("--name", ctx.attr.name) + args.add("--platform", PLATFORM_MAP[platform]) + if ctx.attr.verbose: + args.add("--verbose", ctx.attr.verbose) + + args.add_all( + xctests, + before_each = "--xctest", + expand_directories = False, + ) + + args.add("--output", output.path) + + ctx.actions.run( + inputs = depset(transitive = [xctests, infoplists]), + outputs = [output], + executable = ctx.attr._xctrunnertool[DefaultInfo].files_to_run, + arguments = [args], + mnemonic = "MakeXCTRunner", + ) + + # Limiting the contents of AppleBundleInfo to what is necessary + # for testing and validation. + bundle_info = new_applebundleinfo( + archive = output, + bundle_name = ctx.attr.name, + bundle_extension = ".app", + bundle_id = "com.apple.test.{}".format(ctx.attr.name), + executable_name = ctx.attr.name, + infoplist = "{}/Info.plist".format(output.path), + platform_type = platform, + product_type = "com.apple.product-type.bundle.ui-testing", + ) + + return [ + DefaultInfo(files = depset([output])), + bundle_info, + ] + +xctrunner = rule( + implementation = _xctrunner_impl, + attrs = { + "tests": attr.label_list( + mandatory = True, + aspects = [test_bundle_info_aspect], + doc = "List of test targets and suites to include.", + ), + "verbose": attr.bool( + mandatory = False, + default = False, + doc = "Print logs from xctrunnertool to console.", + ), + "_xctrunnertool": attr.label( + default = Label("//tools/xctrunnertool:run"), + executable = True, + cfg = "exec", + doc = """ +An executable binary that can merge separate xctest into a single XCTestRunner +bundle. +""", + ), + }, + doc = """ +Packages one or more .xctest bundles into a XCTRunner.app. Retains same +platform and architectures as the given `tests` bundles. + +Example: + +````starlark +load("//apple:xctrunner.bzl", "xctrunner") + +ios_ui_test( + name = "HelloWorldSwiftUITests", + minimum_os_version = "15.0", + runner = "@build_bazel_rules_apple//apple/testing/default_runner:ios_xctestrun_ordered_runner", + test_host = ":HelloWorldSwift", + deps = [":UITests"], +) + +xctrunner( + name = "HelloWorldSwiftXCTRunner", + tests = [":HelloWorldSwiftUITests"], + testonly = True, +) +```` +""", +) diff --git a/apple/xctrunner.bzl b/apple/xctrunner.bzl new file mode 100644 index 0000000000..423d076d4c --- /dev/null +++ b/apple/xctrunner.bzl @@ -0,0 +1,25 @@ +# Copyright 2025 The Bazel Authors. All rights reserved. +# +# 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. + +""" +Rule for creating a XCTRunner.app with one or more .xctest bundles. Retains same +platform and architectures as the given `tests` bundles. +""" + +load( + "//apple/internal:xctrunner.bzl", + _xctrunner = "xctrunner", +) + +xctrunner = _xctrunner diff --git a/doc/BUILD.bazel b/doc/BUILD.bazel index 838a7f8d8a..94e52f598f 100644 --- a/doc/BUILD.bazel +++ b/doc/BUILD.bazel @@ -19,6 +19,7 @@ _RULES_DOC_SRCS = [ "visionos.doc", "watchos.doc", "xcarchive", + "xctrunner", ] _DOC_SRCS = _PLAIN_DOC_SRCS + _RULES_DOC_SRCS diff --git a/doc/README.md b/doc/README.md index d333620934..dd2740ec40 100644 --- a/doc/README.md +++ b/doc/README.md @@ -144,6 +144,10 @@ below.
@build_bazel_rules_apple//apple:versioning.bzl
apple_bundle_version
@build_bazel_rules_apple//apple:xctrunner.bzl
xctrunner
+xctrunner(name, tests, verbose) ++ +Packages one or more .xctest bundles into a XCTRunner.app. Retains same +platform and architectures as the given `tests` bundles. + +Example: + +````starlark +load("//apple:xctrunner.bzl", "xctrunner") + +ios_ui_test( + name = "HelloWorldSwiftUITests", + minimum_os_version = "15.0", + runner = "@build_bazel_rules_apple//apple/testing/default_runner:ios_xctestrun_ordered_runner", + test_host = ":HelloWorldSwift", + deps = [":UITests"], +) + +xctrunner( + name = "HelloWorldSwiftXCTRunner", + tests = [":HelloWorldSwiftUITests"], + testonly = True, +) +```` + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| tests | List of test targets and suites to include. | List of labels | required | | +| verbose | Print logs from xctrunnertool to console. | Boolean | optional | `False` | + + diff --git a/examples/ios/HelloWorldSwift/BUILD b/examples/ios/HelloWorldSwift/BUILD index 8471c9a80c..01c550a925 100644 --- a/examples/ios/HelloWorldSwift/BUILD +++ b/examples/ios/HelloWorldSwift/BUILD @@ -2,6 +2,7 @@ load("@bazel_skylib//rules:build_test.bzl", "build_test") load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") load("//apple:docc.bzl", "docc_archive") load("//apple:ios.bzl", "ios_application", "ios_ui_test", "ios_unit_test") +load("//apple:xctrunner.bzl", "xctrunner") licenses(["notice"]) @@ -103,3 +104,9 @@ docc_archive( fallback_display_name = "HelloWorldSwift", minimum_access_level = "internal", ) + +xctrunner( + name = "HelloWorldSwiftXCTRunner", + testonly = True, + tests = [":HelloWorldSwiftUITests"], +) diff --git a/test/starlark_tests/BUILD b/test/starlark_tests/BUILD index c6cd6e2c40..f18ef9a4d4 100644 --- a/test/starlark_tests/BUILD +++ b/test/starlark_tests/BUILD @@ -59,6 +59,7 @@ load(":watchos_static_framework_tests.bzl", "watchos_static_framework_test_suite load(":watchos_ui_test_tests.bzl", "watchos_ui_test_test_suite") load(":watchos_unit_test_tests.bzl", "watchos_unit_test_test_suite") load(":xcarchive_tests.bzl", "xcarchive_test_suite") +load(":xctrunner_tests.bzl", "xctrunner_test_suite") licenses(["notice"]) @@ -183,6 +184,8 @@ watchos_unit_test_test_suite(name = "watchos_unit_test") xcarchive_test_suite(name = "xcarchive") +xctrunner_test_suite(name = "xctrunner") + docc_test_suite(name = "docc") test_suite(name = "all_tests") diff --git a/test/starlark_tests/targets_under_test/ios/BUILD b/test/starlark_tests/targets_under_test/ios/BUILD index 3a255f403e..a1056efa94 100644 --- a/test/starlark_tests/targets_under_test/ios/BUILD +++ b/test/starlark_tests/targets_under_test/ios/BUILD @@ -36,6 +36,10 @@ load( "//apple:xcarchive.bzl", "xcarchive", ) +load( + "//apple:xctrunner.bzl", + "xctrunner", +) load( "//test/starlark_tests:common.bzl", "common", @@ -3366,6 +3370,21 @@ ios_ui_test( ], ) +xctrunner( + name = "ui_test_xctrunner_app", + testonly = True, + tests = [":ui_test"], +) + +xctrunner( + name = "ui_test_xctrunner_app_multiple_targets", + testonly = True, + tests = [ + ":ui_test", + ":ui_test_with_fmwk", + ], +) + # --------------------------------------------------------------------------------------- ios_unit_test( diff --git a/test/starlark_tests/xctrunner_tests.bzl b/test/starlark_tests/xctrunner_tests.bzl new file mode 100644 index 0000000000..ab6680eba1 --- /dev/null +++ b/test/starlark_tests/xctrunner_tests.bzl @@ -0,0 +1,66 @@ +# Copyright 2024 The Bazel Authors. All rights reserved. +# +# 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. + +"""xctrunner Starlark tests.""" + +load( + "//test/starlark_tests/rules:common_verification_tests.bzl", + "archive_contents_test", +) + +def xctrunner_test_suite(name): + """Test suite for xctrunner rule. + + Args: + name: the base name to be used in things created by this macro + """ + + # Verify xctrunner bundles required files for device. + archive_contents_test( + name = "{}_contains_xctrunner_files_device".format(name), + build_type = "device", + target_under_test = "//test/starlark_tests/targets_under_test/ios:ui_test_xctrunner_app", + contains = [ + "$BUNDLE_ROOT/Info.plist", + "$BUNDLE_ROOT/Plugins/ui_test.xctest", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "CFBundleExecutable": "ui_test_xctrunner_app", + "CFBundleIdentifier": "com.apple.test.ui_test_xctrunner_app", + "CFBundleName": "ui_test_xctrunner_app", + "DTPlatformName": "iphoneos", + }, + tags = [name], + ) + + # Verify xctrunner bundles multiple targets for device. + archive_contents_test( + name = "{}_contains_multiple_targets_device".format(name), + build_type = "device", + target_under_test = "//test/starlark_tests/targets_under_test/ios:ui_test_xctrunner_app_multiple_targets", + contains = [ + "$BUNDLE_ROOT/Info.plist", + "$BUNDLE_ROOT/Plugins/ui_test.xctest", + "$BUNDLE_ROOT/Plugins/ui_test_with_fmwk.xctest", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "CFBundleExecutable": "ui_test_xctrunner_app_multiple_targets", + "CFBundleIdentifier": "com.apple.test.ui_test_xctrunner_app_multiple_targets", + "CFBundleName": "ui_test_xctrunner_app_multiple_targets", + "DTPlatformName": "iphoneos", + }, + tags = [name], + ) diff --git a/tools/wrapper_common/BUILD b/tools/wrapper_common/BUILD index 0423cd38a0..435e55d401 100644 --- a/tools/wrapper_common/BUILD +++ b/tools/wrapper_common/BUILD @@ -11,6 +11,7 @@ py_library( "//tools/swift_stdlib_tool:__pkg__", "//tools/xcarchivetool:__pkg__", "//tools/xctoolrunner:__pkg__", + "//tools/xctrunnertool:__pkg__", ], ) diff --git a/tools/xctrunnertool/BUILD.bazel b/tools/xctrunnertool/BUILD.bazel new file mode 100644 index 0000000000..b385cba39f --- /dev/null +++ b/tools/xctrunnertool/BUILD.bazel @@ -0,0 +1,14 @@ +py_library( + name = "lib", + srcs = glob(["lib/*.py"]), + imports = ["."], + visibility = ["//visibility:public"], +) + +py_binary( + name = "run", + srcs = ["run.py"], + imports = ["."], + visibility = ["//visibility:public"], + deps = [":lib"], +) diff --git a/tools/xctrunnertool/lib/dependencies.py b/tools/xctrunnertool/lib/dependencies.py new file mode 100644 index 0000000000..dbd8187b93 --- /dev/null +++ b/tools/xctrunnertool/lib/dependencies.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +""" +List of dependencies (frameworks, private frameworks, dylibs, etc.) +to copy to the test bundle. +""" + +FRAMEWORK_DEPS = [ + "XCTest.framework", + "Testing.framework", # Xcode 16+ +] + +PRIVATE_FRAMEWORK_DEPS = [ + "XCTAutomationSupport.framework", # Xcode 15+ + "XCTestCore.framework", + "XCTestSupport.framework", + "XCUIAutomation.framework", + "XCUnit.framework", +] + +DYLIB_DEPS = [ + "libXCTestBundleInject.dylib", + "libXCTestSwiftSupport.dylib", +] diff --git a/tools/xctrunnertool/lib/lipo_util.py b/tools/xctrunnertool/lib/lipo_util.py new file mode 100644 index 0000000000..0a71004f80 --- /dev/null +++ b/tools/xctrunnertool/lib/lipo_util.py @@ -0,0 +1,37 @@ +#!/usr/binary/env python3 + +import shutil +import logging +from lib.shell import shell + + +class LipoUtil: + "Lipo utility class." + + def __init__(self): + self.lipo_path = shutil.which("lipo") + self.log = logging.getLogger(__name__) + + def info(self, binary: str) -> str: + "Returns the lipo info for the given binary." + cmd = f"{self.lipo_path} -info {binary}" + return shell(cmd, check_status=False) + + def current_archs(self, binary: str) -> list: + "Returns the list of architectures in the given binary." + archs = self.info(binary) + try: + return archs.split("is architecture: ")[1].split() + except IndexError: + return archs.split("are: ")[1].split() + + def extract_or_thin(self, binary: str, archs: list[str]): + "Keeps only the given archs in the given binary." + cmd = [self.lipo_path, binary] + if len(archs) == 1: + cmd.extend(["-thin", archs[0]]) + else: + for arch in archs: + cmd.extend(["-extract", arch]) + cmd.extend(["-output", binary]) + shell(" ".join(cmd)) diff --git a/tools/xctrunnertool/lib/logger.py b/tools/xctrunnertool/lib/logger.py new file mode 100644 index 0000000000..b2673fef70 --- /dev/null +++ b/tools/xctrunnertool/lib/logger.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +import logging +import os +import sys +from lib.model import Configuration + + +class StreamToLogger(object): + """ + Fake file-like stream object that redirects writes to a logger instance. + """ + + def __init__(self, logger, level): + self.logger = logger + self.level = level + self.linebuf = "" + + def write(self, buf): + "Writes to file" + for line in buf.rstrip().splitlines(): + self.logger.log(self.level, line.rstrip()) + + def flush(self): + "Flushes IO buffer" + pass + + +class Logger: + "Logger class." + + def __init__(self, config: Configuration, level: int = logging.INFO): + if config.verbose_logging: + level = logging.DEBUG + + logging.basicConfig( + format="%(asctime)s MakeXCTRunner %(levelname)-8s %(message)s", + level=level, + datefmt="%Y-%m-%d %H:%M:%S %z", + filename=config.log_output, + ) + + if config.verbose_logging: + # Add console logger in addition to a file logger + console = logging.StreamHandler() + console.setLevel(level) + formatter = logging.Formatter( + "%(asctime)s MakeXCTRunner %(levelname)-8s %(message)s" + ) + console.setFormatter(formatter) + logging.getLogger("").addHandler(console) + + def get(self, name: str) -> logging.Logger: + "Returns logger with the given name." + log = logging.getLogger(name) + sys.stderr = StreamToLogger(log, logging.ERROR) + return log diff --git a/tools/xctrunnertool/lib/model.py b/tools/xctrunnertool/lib/model.py new file mode 100644 index 0000000000..f151694126 --- /dev/null +++ b/tools/xctrunnertool/lib/model.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +from dataclasses import dataclass +from typing import List + + +@dataclass +class XcodeConfig: + "Configuration for Xcode in use." + path: str + platform: str + developer_dir: str = "" + libraries_dir: str = "" + frameworks_dir: str = "" + private_frameworks_dir: str = "" + dylib_dir: str = "" + + def __post_init__(self): + self.developer_dir = f"{self.path}/Platforms/{self.platform}/Developer" + self.libraries_dir = f"{self.developer_dir}/Library" + self.frameworks_dir = f"{self.libraries_dir}/Frameworks" + self.private_frameworks_dir = f"{self.libraries_dir}/PrivateFrameworks" + self.dylib_dir = f"{self.developer_dir}/usr/lib" + + +@dataclass +class XCTRunnerConfig: + "Configuration for XCTRunner." + xcode: XcodeConfig + name: str = "XCTRunner" + bundle_identifier: str = "" + info_plist_path: str = "" + path: str = "" + template_path: str = "" + + def __post_init__(self): + self.app = f"{self.name}.app" + self.template_path = f"{self.xcode.libraries_dir}/Xcode/Agents/XCTRunner.app" + self.bundle_identifier = f"com.apple.test.{self.name}" + self.info_plist_path = f"{self.path}/Info.plist" + + +@dataclass +class Configuration: + "Configuration for the generator." + xctrunner_path: str + platform: str + name: str + xctests: List[str] + xcode_path: str + log_output: str = "make_xctrunner.log" + verbose_logging: bool = False + xcode: XcodeConfig = None + xctrunner: XCTRunnerConfig = None + + def __post_init__(self): + self.xcode = XcodeConfig(path=self.xcode_path, platform=self.platform) + self.xctrunner = XCTRunnerConfig( + name=self.name, path=self.xctrunner_path, xcode=self.xcode + ) diff --git a/tools/xctrunnertool/lib/shell.py b/tools/xctrunnertool/lib/shell.py new file mode 100644 index 0000000000..e0d77d2c74 --- /dev/null +++ b/tools/xctrunnertool/lib/shell.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import logging +import subprocess +import os +import shutil + + +def shell(command: str, check_status: bool = True) -> str: + "Runs given shell command and returns stdout output." + log = logging.getLogger(__name__) + try: + log.debug("Running shell command: %s", command) + output = subprocess.run( + command, shell=True, check=check_status, capture_output=True + ).stdout + return output.decode("utf-8").strip() + except subprocess.CalledProcessError as e: + log.error("Shell command failed: %s", e) + raise e + + +def cp_r(src, dst): + "Copies src recursively to dst and chmod with full access." + os.makedirs(dst, exist_ok=True) # create dst if it doesn't exist + shutil.copytree(src, dst, dirs_exist_ok=True) diff --git a/tools/xctrunnertool/run.py b/tools/xctrunnertool/run.py new file mode 100755 index 0000000000..adbeba57a6 --- /dev/null +++ b/tools/xctrunnertool/run.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 + +import argparse +import sys +import shutil +import os +import plistlib + +from lib.logger import Logger +from lib.shell import shell, cp_r +from lib.model import Configuration +from lib.lipo_util import LipoUtil +from lib.dependencies import FRAMEWORK_DEPS, PRIVATE_FRAMEWORK_DEPS, DYLIB_DEPS + + +class DefaultHelpParser(argparse.ArgumentParser): + """Argument parser error.""" + + def error(self, message): + sys.stderr.write(f"error: {message}\n") + self.print_help() + sys.exit(2) + + +def main(argv) -> None: + "Script entrypoint." + parser = DefaultHelpParser() + parser.add_argument( + "--name", + required=True, + help="Name for the merged test bundle.", + ) + parser.add_argument( + "--platform", + default="iPhoneOS.platform", + help="Runtime platform. Default: iPhoneOS.platform", + ) + parser.add_argument( + "--output", + required=True, + help="Output path for merged test bundle.", + ) + parser.add_argument( + "--xctest", + required=True, + action="append", + help="Path to xctest archive to bundle.", + ) + parser.add_argument( + "--verbose", + required=False, + default=False, + type=bool, + help="Enable verbose logging to console.", + ) + args = parser.parse_args() + + # Generator configuration + xcode_path = shell("xcode-select -p").strip() + config = Configuration( + name=args.name, + xctests=args.xctest, + platform=args.platform, + xctrunner_path=args.output, + xcode_path=xcode_path, + verbose_logging=args.verbose, + ) + + # Shared logger + log = Logger(config).get(__name__) + + # Log configuration + log.info("Bundle: %s", config.xctrunner.app) + log.info("Platform: %s", config.platform) + log.info("Xcode: %s", config.xcode.path) + xctest_names = ", ".join([os.path.basename(x) for x in config.xctests]) + log.info("XCTests: %s", xctest_names) + log.info("Output: %s", config.xctrunner.path) + + # Copy XCTRunner.app template + log.info("Copying XCTRunner.app Template: %s", config.xctrunner.path) + shutil.rmtree( + config.xctrunner.path, ignore_errors=True + ) # Clean up any existing bundle + cp_r(config.xctrunner.template_path, config.xctrunner.path) + + # Rename XCTRunner binary to match the bundle name + os.rename( + f"{config.xctrunner.path}/XCTRunner", + f"{config.xctrunner.path}/{config.xctrunner.name}", + ) + + # Create PlugIns and Frameworks directories + os.makedirs(f"{config.xctrunner.path}/PlugIns", exist_ok=True) + os.makedirs(f"{config.xctrunner.path}/Frameworks", exist_ok=True) + + # Move each xctest bundle into PlugIns directory and get + # architecture info. + lipo = LipoUtil() + xctest_archs = [] + for xctest in config.xctests: + name = os.path.basename(xctest).split(".")[ + 0 + ] # MyTests.__internal__.__test_bundle.extension -> MyTests + bundle = f"{name}.xctest" # MyTest.xctest + plugins = f"{config.xctrunner.path}/PlugIns" + + # Unzip if needed + if xctest.endswith(".zip"): + log.debug( + "Unzipping: %s -> %s/%s", + xctest, + plugins, + bundle, + ) + shell(f"unzip -q -o {xctest} -d {plugins}/") # .../PlugIns/MyTest.xctest + bin_path = f"{plugins}/{bundle}/{name}" # .../PlugIns/MyTest.xctest/MyTest + else: + log.debug("Copying: %s -> %s/%s", xctest, plugins, bundle) + cp_r(xctest, f"{plugins}/{bundle}/") # .../Plugins/MyTest.xctest + bin_path = f"{plugins}/{bundle}/{name}" # .../Plugins/MyTest.xctest/MyTest + + # Get architecture info for each binary + log.debug("Lipo: XCTest binary - %s", bin_path) + archs = lipo.current_archs(bin_path) + log.debug("Lipo: %s archs: %s)", name, archs) + xctest_archs.extend(archs) + + archs_to_keep = list(set(xctest_archs)) # unique + log.info("Bundle Architectures: %s)", archs_to_keep) + + # Remove unwanted architectures from XCTRunner bundle + lipo.extract_or_thin( + f"{config.xctrunner.path}/{config.xctrunner.name}", archs_to_keep + ) + + # Update Info.plist with bundle info + with open(config.xctrunner.info_plist_path, "rb") as content: + plist = plistlib.load(content) + plist["CFBundleName"] = config.xctrunner.name + plist["CFBundleExecutable"] = config.xctrunner.name + plist["CFBundleIdentifier"] = config.xctrunner.bundle_identifier + plistlib.dump(plist, open(config.xctrunner.info_plist_path, "wb")) + + # Copy dependencies to the bundle and remove unwanted architectures + for framework in FRAMEWORK_DEPS: + log.info("Bundling fwk: %s", framework) + fwk_path = f"{config.xcode.frameworks_dir}/{framework}" + + # Older Xcode versions may not have some of the frameworks + if not os.path.exists(fwk_path): + log.warning("Framework '%s' not available at %s", framework, fwk_path) + continue + + cp_r( + fwk_path, + f"{config.xctrunner.path}/Frameworks/{framework}", + ) + fwk_binary = framework.replace(".framework", "") + bin_path = f"{config.xctrunner.path}/Frameworks/{framework}/{fwk_binary}" + lipo.extract_or_thin( + bin_path, archs_to_keep + ) # Strip architectures not in test bundles. + + for framework in PRIVATE_FRAMEWORK_DEPS: + log.info("Bundling fwk: %s", framework) + cp_r( + f"{config.xcode.private_frameworks_dir}/{framework}", + f"{config.xctrunner.path}/Frameworks/{framework}", + ) + fwk_binary = framework.replace(".framework", "") + bin_path = f"{config.xctrunner.path}/Frameworks/{framework}/{fwk_binary}" + lipo.extract_or_thin(bin_path, archs_to_keep) + + for dylib in DYLIB_DEPS: + log.info("Bundling dylib: %s", dylib) + shutil.copy( + f"{config.xcode.dylib_dir}/{dylib}", + f"{config.xctrunner.path}/Frameworks/{dylib}", + ) + lipo.extract_or_thin( + f"{config.xctrunner.path}/Frameworks/{dylib}", archs_to_keep + ) + + log.info("Output: %s", f"{config.xctrunner.path}") + log.info("Done.") + + +if __name__ == "__main__": + main(sys.argv[1:])