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..3a493eebb0 --- /dev/null +++ b/apple/internal/xctrunner.bzl @@ -0,0 +1,157 @@ +""" +Rule for merging multiple test targets into a single XCTRunner.app bundle. +""" + +load( + "//apple:providers.bzl", + "AppleBundleInfo", +) +load( + "//apple/internal:providers.bzl", + "new_applebinaryinfo", +) + +_TestBundleInfo = provider( + "Test bundle info for tests that will be run.", + fields = { + "infoplists": "A `depset` of `File`s of `Info.plist` files.", + "xctests": "A `depset` of paths of XCTest bundles.", + }, +) + +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]) + 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 + ], + ) + + return [ + _TestBundleInfo( + infoplists = infoplists, + xctests = xctests, + ), + ] + +test_bundle_info_aspect = aspect( + attr_aspects = ["tests"], + implementation = _test_bundle_info_aspect_impl, +) + +def _xctrunner_impl(ctx): + # Get test target info + infos = [target[_TestBundleInfo] for target in ctx.attr.test_targets] + infoplists = depset( + transitive = [info.infoplists for info in infos], + ) + xctests = depset( + transitive = [info.xctests for info in infos], + ) + output = ctx.actions.declare_directory(ctx.label.name + ".app") + + # Args for _make_xctrunner + arguments = ctx.actions.args() + arguments.add("--name", ctx.label.name) + arguments.add("--platform", ctx.attr.platform) + + if ctx.attr.verbose: + arguments.add("--verbose", ctx.attr.verbose) + + # Absolute paths to xctest bundles + arguments.add_all( + xctests, + before_each = "--xctest", + expand_directories = False, + ) + + # App bundle output path + arguments.add("--output", output.path) + + ctx.actions.run( + inputs = depset(transitive = [xctests, infoplists]), + outputs = [output], + executable = ctx.executable._make_xctrunner, + arguments = [arguments], + mnemonic = "MakeXCTRunner", + ) + + # Limiting the contents of AppleBinaryInfo to what is necessary + # for testing and validation. + xctrunner_binary_info = new_applebinaryinfo( + binary = output, + infoplist = None, + product_type = None, + ) + + return [ + DefaultInfo(files = depset([output])), + xctrunner_binary_info, + ] + +xctrunner = rule( + implementation = _xctrunner_impl, + attrs = { + "platform": attr.string( + default = "iPhoneOS.platform", + mandatory = False, + doc = "Platform to bundle for. Default: iPhoneOS.platform", + ), + "test_targets": 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.", + ), + "_make_xctrunner": 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. + +Note: Tests inside must be qualified with the test target +name as `testTargetName/testClass/testCase` for device farm builds. + +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", + test_targets = [":HelloWorldSwiftUITests"], + testonly = True, +) +```` + """, +) diff --git a/apple/xctrunner.bzl b/apple/xctrunner.bzl new file mode 100644 index 0000000000..5d27c95338 --- /dev/null +++ b/apple/xctrunner.bzl @@ -0,0 +1,24 @@ +# 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. + +""" +Rule for creating a XCTRunner.app with one or more .xctest 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 + Resources diff --git a/doc/rules-xctrunner.md b/doc/rules-xctrunner.md new file mode 100644 index 0000000000..3344350f95 --- /dev/null +++ b/doc/rules-xctrunner.md @@ -0,0 +1,48 @@ + + +Rule for creating a XCTRunner.app with one or more .xctest bundles. + + + +## xctrunner + +
+xctrunner(name, platform, test_targets, verbose)
+
+ +Packages one or more .xctest bundles into a XCTRunner.app. + +Note: Tests inside must be qualified with the test target +name as `testTargetName/testClass/testCase` for device farm builds. + +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", + test_targets = [":HelloWorldSwiftUITests"], + testonly = True, +) +```` + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| platform | Platform to bundle for. Default: iPhoneOS.platform | String | optional | `"iPhoneOS.platform"` | +| test_targets | 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..96de8b85af 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, + test_targets = [":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..5172822df3 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, + test_targets = [":ui_test"], +) + +xctrunner( + name = "ui_test_xctrunner_app_multiple_targets", + testonly = True, + test_targets = [ + ":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..c07b993ffe --- /dev/null +++ b/test/starlark_tests/xctrunner_tests.bzl @@ -0,0 +1,68 @@ +# 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/XCTRunner.app", + "$BUNDLE_ROOT/Plugins/ui_test.xctest", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "CFBundleExecutable": "XCTRunner", + "CFBundleIdentifier": "com.apple.test.XCTRunner", + "CFBundleName": "XCTRunner", + "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/XCTRunner.app", + "$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": "XCTRunner", + "CFBundleIdentifier": "com.apple.test.XCTRunner", + "CFBundleName": "XCTRunner", + "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..70350a4695 --- /dev/null +++ b/tools/xctrunnertool/BUILD.bazel @@ -0,0 +1,16 @@ +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..b442bdcd4a --- /dev/null +++ b/tools/xctrunnertool/lib/lipo_util.py @@ -0,0 +1,26 @@ +#!/usr/bin/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 has_arch(self, bin: str, arch: str) -> bool: + "Returns True if the given binary has the given arch." + cmd = f"{self.lipo_path} -info {bin}" + output = shell(cmd, check_status=False) + + return arch in output + + def remove_arch(self, bin: str, arch: str): + "Removes the given arch from the binary." + if self.has_arch(bin, arch): + cmd = f"{self.lipo_path} {bin} -remove {arch} -output {bin}" + shell(cmd) diff --git a/tools/xctrunnertool/lib/logger.py b/tools/xctrunnertool/lib/logger.py new file mode 100644 index 0000000000..fa1cf7ecba --- /dev/null +++ b/tools/xctrunnertool/lib/logger.py @@ -0,0 +1,55 @@ +#!/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: str = "INFO"): + 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() + lvl = (os.environ.get("CONSOLE_LOG_LEVEL") or "info").upper() + console.setLevel(lvl) + 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..967350110b --- /dev/null +++ b/tools/xctrunnertool/lib/model.py @@ -0,0 +1,63 @@ +#!/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) + + # Note: Do not change the name or some device farms, like BrowserStack, + # refuse to run the bundle and claim it is invalid. + self.xctrunner = XCTRunnerConfig( + name="XCTRunner", path=self.xctrunner_path, xcode=self.xcode + ) diff --git a/tools/xctrunnertool/lib/plist_util.py b/tools/xctrunnertool/lib/plist_util.py new file mode 100644 index 0000000000..389c949a2a --- /dev/null +++ b/tools/xctrunnertool/lib/plist_util.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +import plistlib + + +class PlistUtil: + """Plist utility class.""" + + def __init__(self, plist_path): + self.plist_path = plist_path + with open(plist_path, "rb") as content: + self.plist = plistlib.load(content) + + def update(self, key, value): + "Updates given plist key with given value." + self.plist[key] = value + plistlib.dump(self.plist, open(self.plist_path, "wb")) 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..8bc41147fe --- /dev/null +++ b/tools/xctrunnertool/run.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + +import argparse +import sys +import shutil +import os + +from lib.logger import Logger +from lib.shell import shell, cp_r +from lib.model import Configuration +from lib.plist_util import PlistUtil +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 to %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) + + # XCTRunner is multi-arch. When launching XCTRunner on arm64e device, it + # will be launched as arm64e process by default. If the test bundle is arm64e + # bundle, the XCTRunner which hosts the test bundle will fail to be + # launched. So removing the arm64e arch from XCTRunner can resolve this + # case. + lipo = LipoUtil() + lipo.remove_arch( + bin=f"{config.xctrunner.path}/{config.xctrunner.name}", arch="arm64e" + ) + + # 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 + for xctest in config.xctests: + name = os.path.basename(xctest) + destination = f"{config.xctrunner.path}/PlugIns/{name}" + + # Unzip if needed + if xctest.endswith(".zip"): + log.info("Unzipping and copying xctest '%s' to '%s", xctest, destination) + shell(f"unzip -q -o {xctest} -d {config.xctrunner.path}/PlugIns") + continue + else: + log.info( + "Copying xctest '%s' to %s", + xctest, + f"{config.xctrunner.path}/PlugIns/{name}", + ) + cp_r(xctest, destination) + + # Update Info.plist with bundle info + plist = PlistUtil(plist_path=config.xctrunner.info_plist_path) + plist.update("CFBundleName", config.xctrunner.name) + plist.update("CFBundleExecutable", config.xctrunner.name) + plist.update("CFBundleIdentifier", config.xctrunner.bundle_identifier) + + # 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.remove_arch(bin_path, "arm64e") + + 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.remove_arch(bin_path, "arm64e") + + 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.remove_arch(f"{config.xctrunner.path}/Frameworks/{dylib}", "arm64e") + + # Copy the template to the root of the main bundle - following + # what Xcode does. + cp_r( + config.xctrunner.template_path, + f"{config.xctrunner.path}/{config.xctrunner.app}", + ) + + log.info("Output: %s", f"{config.xctrunner.path}") + log.info("Done.") + + +if __name__ == "__main__": + main(sys.argv[1:])