From 8ae0d63ecf24583ef39f45af933e2ffb3e586d31 Mon Sep 17 00:00:00 2001 From: Lakshya Kapoor <4314581+kapoorlakshya@users.noreply.github.com> Date: Tue, 10 Sep 2024 09:55:43 -0700 Subject: [PATCH] Add xctrunnertool to create test bundles --- apple/testing/xctrunner.bzl | 63 ++++++++ tools/xctrunnertool/BUILD.bazel | 16 ++ tools/xctrunnertool/lib/dependencies.py | 24 +++ tools/xctrunnertool/lib/lipo_util.py | 24 +++ tools/xctrunnertool/lib/logger.py | 53 +++++++ tools/xctrunnertool/lib/model.py | 45 ++++++ tools/xctrunnertool/lib/plist_util.py | 17 ++ tools/xctrunnertool/lib/shell.py | 16 ++ tools/xctrunnertool/run.py | 201 ++++++++++++++++++++++++ 9 files changed, 459 insertions(+) create mode 100644 apple/testing/xctrunner.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/testing/xctrunner.bzl b/apple/testing/xctrunner.bzl new file mode 100644 index 0000000000..71d75212ec --- /dev/null +++ b/apple/testing/xctrunner.bzl @@ -0,0 +1,63 @@ +""" +Rule for merging multiple test targets into a single XCTRunner.app bundle. +""" + +load("@build_bazel_rules_apple//apple:providers.bzl", "AppleBundleInfo") + +def _ios_test_runner_bundle_impl(ctx): + # Get test target info + xctrunner_output = ctx.label.name + "_xctrunner" + 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 + merged_bundle = ctx.actions.declare_directory(xctrunner_output) # output dir + + # 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", merged_bundle.path) + + ctx.actions.run( + inputs = depset(xctests + infoplist), + outputs = [merged_bundle], + executable = ctx.executable._make_xctrunner, + arguments = [arguments], + mnemonic = "MakeXCTRunner", + ) + + return DefaultInfo(files = depset([merged_bundle])) + +ios_test_runner_bundle = rule( + implementation = _ios_test_runner_bundle_impl, + attrs = { + "test_targets": attr.label_list( + mandatory = True, + providers = [ + AppleBundleInfo, + ], + doc = "List of ios_ui_test targets to merge.", + ), + "platform": attr.string( + default = "iPhoneOS.platform", + mandatory = False, + doc = "Platform to bundle for. Defaults to iPhoneOS.platform.", + ), + "_make_xctrunner": attr.label( + default = Label("//tools/snoozel/scripts/make_xctrunner:run"), + executable = True, + cfg = "exec", + doc = "An executable binary that can merge separate xctest into a single XCTestRunner bundle.", + ), + }, +) \ No newline at end of file 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..66d0bdb647 --- /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", +] + +PRIVATE_FRAMEWORK_DEPS = [ + "XCTAutomationSupport.framework", + "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..55b9d7b803 --- /dev/null +++ b/tools/xctrunnertool/lib/lipo_util.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +import shutil +from lib.shell import run_shell_command + + +class LipoUtil: + "Lipo utility class." + + def __init__(self): + self.lipo_path = shutil.which("lipo") + + 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 = run_shell_command(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}" + output = run_shell_command(cmd) + print(output) diff --git a/tools/xctrunnertool/lib/logger.py b/tools/xctrunnertool/lib/logger.py new file mode 100644 index 0000000000..b8986ab99a --- /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, # set at the step level as an env var + 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..021fea5450 --- /dev/null +++ b/tools/xctrunnertool/lib/model.py @@ -0,0 +1,45 @@ +#!/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" + output_dir: 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.output_dir = f"{os.path.dirname(self.output)}/{self.name}" + self.xctrunner_path = f"{self.output_dir}/{self.xctrunner_app}" + self.xctrunner_info_plist_path = f"{self.xctrunner_path}/Info.plist" + + # Create the output directory if missing + os.makedirs(self.output_dir, exist_ok=True) 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..e589333fe5 --- /dev/null +++ b/tools/xctrunnertool/lib/shell.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +import logging +import subprocess + + +def run_shell_command(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 diff --git a/tools/xctrunnertool/run.py b/tools/xctrunnertool/run.py new file mode 100755 index 0000000000..7e8f4f2d8d --- /dev/null +++ b/tools/xctrunnertool/run.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 + +import argparse +import sys +import shutil +import os + +from lib.logger import Logger +from lib.shell import run_shell_command +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 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 + + +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 = run_shell_command("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(f"{config.output_dir}/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.xctrunner_path) + chmod(config.output_dir) # open up for writing + 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-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.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) + log.info( + "Copying xctest '%s' to %s", + xctest, + f"{config.xctrunner_path}/PlugIns/{name}", + ) + cp_r(xctest, f"{config.xctrunner_path}/PlugIns/{name}") + + # Remove UITestFixturesBundle.bundle if present + fixtures_bundle_path = ( + f"{config.xctrunner_path}/PlugIns/{name}/UITestFixturesBundle.bundle" + ) + if os.path.exists(fixtures_bundle_path): + shutil.rmtree(fixtures_bundle_path) + + # 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) + plist.update("DYLD_FRAMEWORK_PATH", config.libraries_dir + "/Frameworks") + + # Copy dependencies to the bundle and remove unwanted architectures + for framework in FRAMEWORK_DEPS: + log.info("Bundling fwk: %s", framework) + cp_r( + f"{config.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 framework in PRIVATE_FRAMEWORK_DEPS: + log.info("Bundling fwk: %s", framework) + cp_r( + f"{config.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) + cp( + f"{config.dylib_dir}/{dylib}", + f"{config.xctrunner_path}/Frameworks/{dylib}", + ) + lipo.remove_arch(f"{config.xctrunner_path}/Frameworks/{dylib}", "arm64e") + + chmod(config.xctrunner_path) # full access to final bundle + log.info("Bundle: %s", config.xctrunner_path) + + # need to copy the template to the root of the main bundle as well + cp_r( + config.xctrunner_template_path, + f"{config.xctrunner_path}/{config.xctrunner_app}/", + ) + chmod(f"{config.xctrunner_path}/") # full access to final bundle + + # Zip the bundle as .app.zip + log.info("Zipping the bundle...") + shutil.make_archive( + f"{config.output_dir}/{config.name}.app", + "zip", + config.output_dir, + config.xctrunner_app, + ) + + # Move the zip to the user given output path + shutil.move( + f"{config.output_dir}/{config.name}.app.zip", + config.output, + ) + log.info("Zip: %s", f"{config.name}.zip") + + log.info("Done.") + + +if __name__ == "__main__": + main(sys.argv[1:])