From ddf9e8d198114f3fdbdfe86019712a76024be4e1 Mon Sep 17 00:00:00 2001 From: Lakshya Kapoor <4314581+kapoorlakshya@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:18:13 -0800 Subject: [PATCH] Add xctrunner and xctrunnertool --- apple/BUILD | 6 + apple/internal/BUILD | 12 ++ apple/internal/xctrunner.bzl | 134 ++++++++++++++++++++ apple/xctrunner.bzl | 24 ++++ doc/BUILD.bazel | 1 + doc/README.md | 4 + doc/rules-xctrunner.md | 47 +++++++ 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 | 54 ++++++++ tools/xctrunnertool/lib/model.py | 40 ++++++ tools/xctrunnertool/lib/plist_util.py | 17 +++ tools/xctrunnertool/lib/shell.py | 43 +++++++ tools/xctrunnertool/run.py | 157 ++++++++++++++++++++++++ 18 files changed, 761 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 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..72fa9d09a5 --- /dev/null +++ b/apple/internal/xctrunner.bzl @@ -0,0 +1,134 @@ +""" +Rule for merging multiple test targets into a single XCTRunner.app bundle. +""" + +load( + "//apple:providers.bzl", + "AppleBundleInfo", +) + +_TestTargetInfo = provider( + "Test target 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_target_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[_TestTargetInfo].xctests + for dep in deps + ], + ) + infoplists = depset( + transitive = [ + dep[_TestTargetInfo].infoplists + for dep in deps + ], + ) + + return [ + _TestTargetInfo( + infoplists = infoplists, + xctests = xctests, + ), + ] + +test_target_info_aspect = aspect( + attr_aspects = ["tests"], + implementation = _test_target_info_aspect_impl, +) + +def _xctrunner_impl(ctx): + # Get test target info + infos = [target[_TestTargetInfo] 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) + + # 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", + ) + + return DefaultInfo(files = depset([output])) + +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_target_info_aspect], + doc = "List of test targets and suites to include.", + ), + "_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..d50afb04ab --- /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 \ 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 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, platform, test_targets) ++ +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 | | + + diff --git a/examples/ios/HelloWorldSwift/BUILD b/examples/ios/HelloWorldSwift/BUILD index 8471c9a80c..16a0aea7eb 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..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..baf68eaeea --- /dev/null +++ b/tools/xctrunnertool/lib/logger.py @@ -0,0 +1,54 @@ +#!/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, + ) + + # TODO: Remove from final version. + # 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..b2b7ba6b20 --- /dev/null +++ b/tools/xctrunnertool/lib/model.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +from dataclasses import dataclass +from typing import List + + +# TODO: Break this down into smaller accessors +@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..7a188e16e8 --- /dev/null +++ b/tools/xctrunnertool/lib/shell.py @@ -0,0 +1,43 @@ +#!/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..e36744431c --- /dev/null +++ b/tools/xctrunnertool/run.py @@ -0,0 +1,157 @@ +#!/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="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.", + ) + 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-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.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") + + # 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 + + # full access to final bundle + chmod(config.output) + log.info("Output: %s", f"{config.output}") + log.info("Done.") + + +if __name__ == "__main__": + main(sys.argv[1:])