From 86139abb226531846cf3a8fea1deb8c5d7b8367b Mon Sep 17 00:00:00 2001 From: Lakshya Kapoor <4314581+kapoorlakshya@users.noreply.github.com> Date: Fri, 13 Sep 2024 16:42:08 -0700 Subject: [PATCH] Add xctrunner and xctrunnertool --- apple/BUILD | 6 + apple/internal/BUILD | 12 ++ apple/internal/xctrunner.bzl | 98 ++++++++++++++ apple/xctrunner.bzl | 24 ++++ doc/BUILD.bazel | 1 + doc/README.md | 4 + doc/rules-xctrunner.md | 49 +++++++ examples/ios/HelloWorldSwift/BUILD | 7 + test/starlark_tests/xctrunner_tests.bzl | 148 ++++++++++++++++++++ tools/wrapper_common/BUILD | 1 + tools/xctrunnertool/BUILD.bazel | 16 +++ tools/xctrunnertool/lib/dependencies.py | 24 ++++ tools/xctrunnertool/lib/lipo_util.py | 26 ++++ tools/xctrunnertool/lib/logger.py | 53 ++++++++ tools/xctrunnertool/lib/model.py | 40 ++++++ tools/xctrunnertool/lib/plist_util.py | 17 +++ tools/xctrunnertool/lib/shell.py | 41 ++++++ tools/xctrunnertool/run.py | 173 ++++++++++++++++++++++++ 18 files changed, 740 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/plist_util.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 ca64340da8..29eb5ce715 100644 --- a/apple/BUILD +++ b/apple/BUILD @@ -252,6 +252,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 f676fa27fc..5572ad3e2a 100644 --- a/apple/internal/BUILD +++ b/apple/internal/BUILD @@ -811,6 +811,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..a4bb119f99 --- /dev/null +++ b/apple/internal/xctrunner.bzl @@ -0,0 +1,98 @@ +""" +Rule for merging multiple test targets into a single XCTRunner.app bundle. +""" + +load("@build_bazel_rules_apple//apple:providers.bzl", "AppleBundleInfo") + +def _xctrunner_impl(ctx): + # Get test target info + bundle_info = [target[AppleBundleInfo] for target in ctx.attr.test_targets] + xctests = [info.archive for info in bundle_info] # xctest bundles + infoplist = [info.infoplist for info in bundle_info] # Info.plist files + 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) + + # absolute paths to xctest bundles + xctest_paths = [xctest.path for xctest in xctests] + arguments.add_all( + xctest_paths, + before_each = "--xctest", + expand_directories = False, + ) + + # app bundle output path + arguments.add("--output", output.path) + + ctx.actions.run( + inputs = depset(xctests + infoplist), + outputs = [output], + executable = ctx.executable._make_xctrunner, + arguments = [arguments], + mnemonic = "MakeXCTRunner", + ) + + return DefaultInfo(files = depset([output])) + +xctrunner = rule( + implementation = _xctrunner_impl, + attrs = { + "test_targets": attr.label_list( + mandatory = True, + providers = [ + AppleBundleInfo, + ], + doc = "List of test targets to include.", + ), + "platform": attr.string( + default = "iPhoneOS.platform", + mandatory = False, + doc = "Platform to bundle for. Default: iPhoneOS.platform", + ), + "arch": attr.string( + default = "arm64", + mandatory = False, + doc = "List of architectures to bundle for. Default: arm64", + ), + "zip": attr.bool( + default = False, + mandatory = False, + doc = "Whether to zip the resulting bundle.", + ), + "_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, +) +```` + """, +) \ No newline at end of file diff --git a/apple/xctrunner.bzl b/apple/xctrunner.bzl new file mode 100644 index 0000000000..808f12ef87 --- /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( + "@build_bazel_rules_apple//apple/internal:xctrunner.bzl", + _xctrunner = "xctrunner", +) + +xctrunner = _xctrunner \ No newline at end of file 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 f5dc033401..ebfc43f4e6 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, arch, platform, test_targets, zip) ++ +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 | | +| arch | List of architectures to bundle for. Default: arm64 | String | optional | `"arm64"` | +| platform | Platform to bundle for. Default: iPhoneOS.platform | String | optional | `"iPhoneOS.platform"` | +| test_targets | List of test targets to include. | List of labels | required | | +| zip | Whether to zip the resulting bundle. | Boolean | optional | `False` | + + diff --git a/examples/ios/HelloWorldSwift/BUILD b/examples/ios/HelloWorldSwift/BUILD index 0dd9a28643..de418cbe6d 100644 --- a/examples/ios/HelloWorldSwift/BUILD +++ b/examples/ios/HelloWorldSwift/BUILD @@ -1,6 +1,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:xctrunner.bzl", "xctrunner") load("//apple:ios.bzl", "ios_application", "ios_ui_test", "ios_unit_test") licenses(["notice"]) @@ -103,3 +104,9 @@ docc_archive( fallback_display_name = "HelloWorldSwift", minimum_access_level = "internal", ) + +xctrunner( + name = "HelloWorldSwiftXCTRunner", + test_targets = [":HelloWorldSwiftUITests"], + testonly = True, +) diff --git a/test/starlark_tests/xctrunner_tests.bzl b/test/starlark_tests/xctrunner_tests.bzl new file mode 100644 index 0000000000..635141a560 --- /dev/null +++ b/test/starlark_tests/xctrunner_tests.bzl @@ -0,0 +1,148 @@ +# 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): + + +def xcarchive_test_suite(name): + """Test suite for xcarchive rule. + + Args: + name: the base name to be used in things created by this macro + """ + + # Verify xcarchive bundles required files and app for simulator and device. + archive_contents_test( + name = "{}_contains_xcarchive_files_simulator".format(name), + build_type = "simulator", + target_under_test = "//test/starlark_tests/targets_under_test/ios:app_minimal.xcarchive", + contains = [ + "$BUNDLE_ROOT/Info.plist", + "$BUNDLE_ROOT/Products/Applications/app_minimal.app", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "ApplicationProperties:ApplicationPath": "Applications/app_minimal.app", + "ApplicationProperties:ArchiveVersion": "2", + "ApplicationProperties:CFBundleIdentifier": "com.google.example", + "ApplicationProperties:CFBundleShortVersionString": "1.0", + "ApplicationProperties:CFBundleVersion": "1.0", + "Name": "app_minimal", + "SchemeName": "app_minimal", + }, + tags = [name], + ) + archive_contents_test( + name = "{}_contains_xcarchive_files_simulator_dsyms".format(name), + build_type = "simulator", + target_under_test = "//test/starlark_tests/targets_under_test/ios:app_minimal.xcarchive", + contains = [ + "$BUNDLE_ROOT/dSYMs/app_minimal.app.dSYM", + "$BUNDLE_ROOT/dSYMs/app_minimal.app.dSYM/Contents/Resources/DWARF/app_minimal", + "$BUNDLE_ROOT/dSYMs/app_minimal.app.dSYM/Contents/Info.plist", + "$BUNDLE_ROOT/Info.plist", + "$BUNDLE_ROOT/Products/Applications/app_minimal.app", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "ApplicationProperties:ApplicationPath": "Applications/app_minimal.app", + "ApplicationProperties:ArchiveVersion": "2", + "ApplicationProperties:CFBundleIdentifier": "com.google.example", + "ApplicationProperties:CFBundleShortVersionString": "1.0", + "ApplicationProperties:CFBundleVersion": "1.0", + "Name": "app_minimal", + "SchemeName": "app_minimal", + }, + apple_generate_dsym = True, + tags = [name], + ) + archive_contents_test( + name = "{}_contains_xcarchive_files_simulator_dsyms_extensions".format(name), + build_type = "simulator", + target_under_test = "//test/starlark_tests/targets_under_test/ios:app_with_ext_space_in_path.xcarchive", + contains = [ + "$BUNDLE_ROOT/dSYMs/app_with_ext_space_in_path.app.dSYM", + "$BUNDLE_ROOT/dSYMs/ext with space.appex.dSYM", + "$BUNDLE_ROOT/dSYMs/ext with space.appex.dSYM/Contents/Resources/DWARF/ext with space", + "$BUNDLE_ROOT/dSYMs/ext with space.appex.dSYM/Contents/Info.plist", + "$BUNDLE_ROOT/Info.plist", + "$BUNDLE_ROOT/Products/Applications/app_with_ext_space_in_path.app", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "ApplicationProperties:ApplicationPath": "Applications/app_with_ext_space_in_path.app", + "ApplicationProperties:ArchiveVersion": "2", + "ApplicationProperties:CFBundleIdentifier": "com.google.example", + "ApplicationProperties:CFBundleShortVersionString": "1.0", + "ApplicationProperties:CFBundleVersion": "1.0", + "Name": "app_with_ext_space_in_path", + "SchemeName": "app_with_ext_space_in_path", + }, + apple_generate_dsym = True, + tags = [name], + ) + archive_contents_test( + name = "{}_contains_xcarchive_files_simulator_linkmaps".format(name), + build_type = "simulator", + target_under_test = "//test/starlark_tests/targets_under_test/ios:app_minimal.xcarchive", + contains = [ + "$BUNDLE_ROOT/Info.plist", + "$BUNDLE_ROOT/Linkmaps/app_minimal_x86_64.linkmap", + "$BUNDLE_ROOT/Products/Applications/app_minimal.app", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "ApplicationProperties:ApplicationPath": "Applications/app_minimal.app", + "ApplicationProperties:ArchiveVersion": "2", + "ApplicationProperties:CFBundleIdentifier": "com.google.example", + "ApplicationProperties:CFBundleShortVersionString": "1.0", + "ApplicationProperties:CFBundleVersion": "1.0", + "Name": "app_minimal", + "SchemeName": "app_minimal", + }, + objc_generate_linkmap = True, + tags = [name], + ) + archive_contents_test( + name = "{}_contains_xcarchive_files_device".format(name), + build_type = "device", + target_under_test = "//test/starlark_tests/targets_under_test/ios:app_minimal.xcarchive", + contains = [ + "$BUNDLE_ROOT/Info.plist", + "$BUNDLE_ROOT/Products/Applications/app_minimal.app", + ], + plist_test_file = "$BUNDLE_ROOT/Info.plist", + plist_test_values = { + "ApplicationProperties:ApplicationPath": "Applications/app_minimal.app", + "ApplicationProperties:ArchiveVersion": "2", + "ApplicationProperties:CFBundleIdentifier": "com.google.example", + "ApplicationProperties:CFBundleShortVersionString": "1.0", + "ApplicationProperties:CFBundleVersion": "1.0", + "Name": "app_minimal", + "SchemeName": "app_minimal", + }, + tags = [name], + ) + + native.test_suite( + name = name, + 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..3fbb2f325c --- /dev/null +++ b/tools/xctrunnertool/lib/lipo_util.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import shutil +from lib.shell import shell +import logging + + +class LipoUtil: + "Lipo utility class." + + def __init__(self): + self.lipo_path = shutil.which("lipo") + self.log = logging.getLogger(__name__) + + def has_arch(self, bin_path: str, arch: str) -> bool: + "Returns True if the given binary has the given arch." + cmd = f"{self.lipo_path} -info {bin_path}" + output = shell(cmd, check_status=False) + + return arch in output + + def remove_arch(self, bin_path: str, arch: str): + "Removes the given arch from the binary." + if self.has_arch(bin_path, arch): + cmd = f"{self.lipo_path} {bin_path} -remove {arch} -output {bin_path}" + shell(cmd) diff --git a/tools/xctrunnertool/lib/logger.py b/tools/xctrunnertool/lib/logger.py new file mode 100644 index 0000000000..4360362b31 --- /dev/null +++ b/tools/xctrunnertool/lib/logger.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import logging +import os +import sys + + +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, filename: str, level: str = "INFO"): + logging.basicConfig( + format="%(asctime)s MakeXCTRunner %(levelname)-8s %(message)s", + level=level, + datefmt="%Y-%m-%d %H:%M:%S %z", + filename=filename, + ) + + # 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..bc36ce0f9e --- /dev/null +++ b/tools/xctrunnertool/lib/model.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +from dataclasses import dataclass +from typing import List +import os + + +@dataclass +class Configuration: + "Configuration for the generator" + name: str + xctests: List[str] + platform: str + output: str + xcode_path: str + xctrunner_name: str = "XCTRunner" + xctrunner_app = "XCTRunner.app" + tmp_output: str = "" + developer_dir: str = "" + libraries_dir: str = "" + frameworks_dir: str = "" + private_frameworks_dir: str = "" + dylib_dir: str = "" + xctrunner_app_name: str = "" + xctrunner_path: str = "" + xctrunner_template_path: str = "" + xctrunner_bundle_identifier: str = "" + xctrunner_info_plist_path: str = "" + + def __post_init__(self): + self.developer_dir = f"{self.xcode_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" + self.xctrunner_template_path = ( + f"{self.libraries_dir}/Xcode/Agents/XCTRunner.app" + ) + self.xctrunner_bundle_identifier = f"com.apple.test.{self.xctrunner_name}" + self.xctrunner_info_plist_path = f"{self.output}/Info.plist" 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..0fe9ca96a2 --- /dev/null +++ b/tools/xctrunnertool/lib/shell.py @@ -0,0 +1,41 @@ +#!/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 chmod(path, mode=0o777): + "Sets path permission recursively." + for dirpath, _, filenames in os.walk(path): + os.chmod(dirpath, mode) + for filename in filenames: + os.chmod(os.path.join(dirpath, filename), mode) + + +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 + chmod(dst) # pessimistically open up for writing + shutil.copytree(src, dst, dirs_exist_ok=True) + chmod(dst) # full access to the copied files + + +def cp(src, dst): + "Copies src file to dst and chmod with full access." + chmod(dst) # pessimistically open up for writing + shutil.copy(src, dst) + chmod(dst) # full access to the copied files diff --git a/tools/xctrunnertool/run.py b/tools/xctrunnertool/run.py new file mode 100755 index 0000000000..6f3c42abee --- /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, chmod, cp, 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="Bundle name for the merged test bundle", + ) + parser.add_argument( + "--xctest", + required=True, + action="append", + help="Path to xctest archive to merge", + ) + parser.add_argument( + "--platform", + default="iPhoneOS.platform", + help="Runtime platform. Default is iPhoneOS.platform", + ) + parser.add_argument( + "--output", + required=True, + help="Output path for merged test bundle.", + ) + args = parser.parse_args() + + # Generator configuration + xcode_path = shell("xcode-select -p").strip() + config = Configuration( + name=args.name, + xctests=args.xctest, + platform=args.platform, + output=args.output, + xcode_path=xcode_path, + ) + + # Shared logger + log = Logger("make_xctrunner.log").get(__name__) + + # Log configuration + log.info("Bundle: %s", config.name) + log.info("Runner: %s", config.xctrunner_app) + xctest_names = ", ".join([os.path.basename(x) for x in config.xctests]) + log.info("XCTests: %s", xctest_names) + log.info("Platform: %s", config.platform) + log.info("Output: %s", config.output) + log.info("Xcode: %s", config.xcode_path) + + # copy XCTRunner.app template + log.info("Copying XCTRunner.app template to %s", config.output) + chmod(config.output) # open up for writing + shutil.rmtree(config.output, ignore_errors=True) # clean up any existing bundle + cp_r(config.xctrunner_template_path, config.output) + + # XCTRunner is multi-archs. 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_path=f"{config.output}/{config.xctrunner_name}", arch="arm64e") + + # Create PlugIns and Frameworks directories + os.makedirs(f"{config.output}/PlugIns", exist_ok=True) + os.makedirs(f"{config.output}/Frameworks", exist_ok=True) + + # Move each xctest bundle into PlugIns directory + for xctest in config.xctests: + name = os.path.basename(xctest) + log.info( + "Copying xctest '%s' to %s", + xctest, + f"{config.output}/PlugIns/{name}", + ) + cp_r(xctest, f"{config.output}/PlugIns/{name}") + + # 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.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.output}/Frameworks/{framework}", + ) # copy to the bundle + fwk_binary = framework.replace(".framework", "") + bin_path = f"{config.output}/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.private_frameworks_dir}/{framework}", + f"{config.output}/Frameworks/{framework}", + ) + fwk_binary = framework.replace(".framework", "") + bin_path = f"{config.output}/Frameworks/{framework}/{fwk_binary}" + lipo.remove_arch(bin_path, "arm64e") + + for dylib in DYLIB_DEPS: + log.info("Bundling dylib: %s", dylib) + cp( + f"{config.dylib_dir}/{dylib}", + f"{config.output}/Frameworks/{dylib}", + ) + lipo.remove_arch(f"{config.output}/Frameworks/{dylib}", "arm64e") + + chmod(config.output) # full access to final bundle + log.info("Bundle: %s", config.output) + + # need to copy the template to the root of the main bundle as well + cp_r( + config.xctrunner_template_path, + f"{config.output}/{config.xctrunner_app}/", + ) + chmod(f"{config.output}/") # full access to final bundle + + # Zip the bundle as