2024-12-09 14:39:11 +08:00
|
|
|
import argparse
|
|
|
|
import json
|
2025-01-25 10:12:32 +01:00
|
|
|
import re
|
2024-12-09 14:39:11 +08:00
|
|
|
import shutil
|
|
|
|
import subprocess
|
|
|
|
import sys
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
DECODE_ARGS = ("UTF-8", "backslashreplace")
|
|
|
|
|
2025-01-25 10:12:32 +01:00
|
|
|
# The system log prefixes each line:
|
2025-08-22 08:30:58 +02:00
|
|
|
# 2025-01-17 16:14:29.093742+0800 iOSTestbed[23987:1fd393b4] ...
|
|
|
|
# 2025-01-17 16:14:29.093742+0800 iOSTestbed[23987:1fd393b4] ...
|
2025-01-25 10:12:32 +01:00
|
|
|
|
|
|
|
LOG_PREFIX_REGEX = re.compile(
|
|
|
|
r"^\d{4}-\d{2}-\d{2}" # YYYY-MM-DD
|
2025-08-22 08:30:58 +02:00
|
|
|
r"\s+\d+:\d{2}:\d{2}\.\d+\+\d{4}" # HH:MM:SS.ssssss+ZZZZ
|
2025-01-25 10:12:32 +01:00
|
|
|
r"\s+iOSTestbed\[\d+:\w+\]" # Process/thread ID
|
|
|
|
)
|
|
|
|
|
2024-12-09 14:39:11 +08:00
|
|
|
|
2025-04-29 23:59:21 +02:00
|
|
|
# Select a simulator device to use.
|
2025-08-22 08:30:58 +02:00
|
|
|
def select_simulator_device():
|
2025-04-29 23:59:21 +02:00
|
|
|
# List the testing simulators, in JSON format
|
2025-08-22 08:30:58 +02:00
|
|
|
raw_json = subprocess.check_output(["xcrun", "simctl", "list", "-j"])
|
2025-04-29 23:59:21 +02:00
|
|
|
json_data = json.loads(raw_json)
|
|
|
|
|
|
|
|
# Any device will do; we'll look for "SE" devices - but the name isn't
|
|
|
|
# consistent over time. Older Xcode versions will use "iPhone SE (Nth
|
|
|
|
# generation)"; As of 2025, they've started using "iPhone 16e".
|
|
|
|
#
|
|
|
|
# When Xcode is updated after a new release, new devices will be available
|
|
|
|
# and old ones will be dropped from the set available on the latest iOS
|
|
|
|
# version. Select the one with the highest minimum runtime version - this
|
|
|
|
# is an indicator of the "newest" released device, which should always be
|
|
|
|
# supported on the "most recent" iOS version.
|
|
|
|
se_simulators = sorted(
|
|
|
|
(devicetype["minRuntimeVersion"], devicetype["name"])
|
|
|
|
for devicetype in json_data["devicetypes"]
|
|
|
|
if devicetype["productFamily"] == "iPhone"
|
|
|
|
and (
|
2025-08-22 08:30:58 +02:00
|
|
|
(
|
|
|
|
"iPhone " in devicetype["name"]
|
|
|
|
and devicetype["name"].endswith("e")
|
|
|
|
)
|
2025-04-29 23:59:21 +02:00
|
|
|
or "iPhone SE " in devicetype["name"]
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
return se_simulators[-1][1]
|
|
|
|
|
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
def xcode_test(location, simulator, verbose):
|
|
|
|
# Build and run the test suite on the named simulator.
|
2024-12-09 14:39:11 +08:00
|
|
|
args = [
|
|
|
|
"-project",
|
|
|
|
str(location / "iOSTestbed.xcodeproj"),
|
|
|
|
"-scheme",
|
|
|
|
"iOSTestbed",
|
|
|
|
"-destination",
|
|
|
|
f"platform=iOS Simulator,name={simulator}",
|
|
|
|
"-derivedDataPath",
|
|
|
|
str(location / "DerivedData"),
|
|
|
|
]
|
2025-08-22 08:30:58 +02:00
|
|
|
verbosity_args = [] if verbose else ["-quiet"]
|
|
|
|
|
|
|
|
print("Building test project...")
|
|
|
|
subprocess.run(
|
|
|
|
["xcodebuild", "build-for-testing"] + args + verbosity_args,
|
|
|
|
check=True,
|
|
|
|
)
|
2024-12-12 23:17:58 +01:00
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
print("Running test project...")
|
|
|
|
# Test execution *can't* be run -quiet; verbose mode
|
|
|
|
# is how we see the output of the test output.
|
|
|
|
process = subprocess.Popen(
|
|
|
|
["xcodebuild", "test-without-building"] + args,
|
2024-12-09 14:39:11 +08:00
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
stderr=subprocess.STDOUT,
|
2025-08-22 08:30:58 +02:00
|
|
|
)
|
|
|
|
while line := (process.stdout.readline()).decode(*DECODE_ARGS):
|
|
|
|
# Strip the timestamp/process prefix from each log line
|
|
|
|
line = LOG_PREFIX_REGEX.sub("", line)
|
|
|
|
sys.stdout.write(line)
|
|
|
|
sys.stdout.flush()
|
2024-12-09 14:39:11 +08:00
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
status = process.wait(timeout=5)
|
|
|
|
exit(status)
|
2024-12-09 14:39:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
def clone_testbed(
|
|
|
|
source: Path,
|
|
|
|
target: Path,
|
|
|
|
framework: Path,
|
|
|
|
apps: list[Path],
|
|
|
|
) -> None:
|
|
|
|
if target.exists():
|
|
|
|
print(f"{target} already exists; aborting without creating project.")
|
|
|
|
sys.exit(10)
|
|
|
|
|
|
|
|
if framework is None:
|
2024-12-12 23:17:58 +01:00
|
|
|
if not (
|
|
|
|
source / "Python.xcframework/ios-arm64_x86_64-simulator/bin"
|
|
|
|
).is_dir():
|
2024-12-09 14:39:11 +08:00
|
|
|
print(
|
|
|
|
f"The testbed being cloned ({source}) does not contain "
|
|
|
|
f"a simulator framework. Re-run with --framework"
|
|
|
|
)
|
|
|
|
sys.exit(11)
|
|
|
|
else:
|
|
|
|
if not framework.is_dir():
|
|
|
|
print(f"{framework} does not exist.")
|
|
|
|
sys.exit(12)
|
|
|
|
elif not (
|
|
|
|
framework.suffix == ".xcframework"
|
|
|
|
or (framework / "Python.framework").is_dir()
|
|
|
|
):
|
|
|
|
print(
|
|
|
|
f"{framework} is not an XCframework, "
|
|
|
|
f"or a simulator slice of a framework build."
|
|
|
|
)
|
|
|
|
sys.exit(13)
|
|
|
|
|
2024-12-12 23:17:58 +01:00
|
|
|
print("Cloning testbed project:")
|
2025-08-22 08:30:58 +02:00
|
|
|
print(f" Cloning {source}...", end="")
|
2024-12-12 23:17:58 +01:00
|
|
|
shutil.copytree(source, target, symlinks=True)
|
|
|
|
print(" done")
|
2024-12-09 14:39:11 +08:00
|
|
|
|
2025-02-13 07:47:05 +01:00
|
|
|
xc_framework_path = target / "Python.xcframework"
|
|
|
|
sim_framework_path = xc_framework_path / "ios-arm64_x86_64-simulator"
|
2024-12-09 14:39:11 +08:00
|
|
|
if framework is not None:
|
|
|
|
if framework.suffix == ".xcframework":
|
2025-08-22 08:30:58 +02:00
|
|
|
print(" Installing XCFramework...", end="")
|
2024-12-12 23:17:58 +01:00
|
|
|
if xc_framework_path.is_dir():
|
|
|
|
shutil.rmtree(xc_framework_path)
|
|
|
|
else:
|
2025-02-13 07:47:05 +01:00
|
|
|
xc_framework_path.unlink(missing_ok=True)
|
2024-12-12 23:17:58 +01:00
|
|
|
xc_framework_path.symlink_to(
|
|
|
|
framework.relative_to(xc_framework_path.parent, walk_up=True)
|
|
|
|
)
|
|
|
|
print(" done")
|
2024-12-09 14:39:11 +08:00
|
|
|
else:
|
2025-08-22 08:30:58 +02:00
|
|
|
print(" Installing simulator framework...", end="")
|
2024-12-12 23:17:58 +01:00
|
|
|
if sim_framework_path.is_dir():
|
|
|
|
shutil.rmtree(sim_framework_path)
|
|
|
|
else:
|
2025-02-13 07:47:05 +01:00
|
|
|
sim_framework_path.unlink(missing_ok=True)
|
2024-12-12 23:17:58 +01:00
|
|
|
sim_framework_path.symlink_to(
|
|
|
|
framework.relative_to(sim_framework_path.parent, walk_up=True)
|
2024-12-09 14:39:11 +08:00
|
|
|
)
|
2024-12-12 23:17:58 +01:00
|
|
|
print(" done")
|
2024-12-09 14:39:11 +08:00
|
|
|
else:
|
2025-02-13 07:47:05 +01:00
|
|
|
if (
|
|
|
|
xc_framework_path.is_symlink()
|
|
|
|
and not xc_framework_path.readlink().is_absolute()
|
|
|
|
):
|
|
|
|
# XCFramework is a relative symlink. Rewrite the symlink relative
|
|
|
|
# to the new location.
|
2025-08-22 08:30:58 +02:00
|
|
|
print(" Rewriting symlink to XCframework...", end="")
|
2025-02-13 07:47:05 +01:00
|
|
|
orig_xc_framework_path = (
|
2025-08-22 08:30:58 +02:00
|
|
|
source / xc_framework_path.readlink()
|
2025-02-13 07:47:05 +01:00
|
|
|
).resolve()
|
|
|
|
xc_framework_path.unlink()
|
|
|
|
xc_framework_path.symlink_to(
|
|
|
|
orig_xc_framework_path.relative_to(
|
|
|
|
xc_framework_path.parent, walk_up=True
|
|
|
|
)
|
|
|
|
)
|
|
|
|
print(" done")
|
|
|
|
elif (
|
|
|
|
sim_framework_path.is_symlink()
|
|
|
|
and not sim_framework_path.readlink().is_absolute()
|
|
|
|
):
|
2025-08-22 08:30:58 +02:00
|
|
|
print(" Rewriting symlink to simulator framework...", end="")
|
2025-02-13 07:47:05 +01:00
|
|
|
# Simulator framework is a relative symlink. Rewrite the symlink
|
|
|
|
# relative to the new location.
|
|
|
|
orig_sim_framework_path = (
|
2025-08-22 08:30:58 +02:00
|
|
|
source / "Python.XCframework" / sim_framework_path.readlink()
|
2025-02-13 07:47:05 +01:00
|
|
|
).resolve()
|
|
|
|
sim_framework_path.unlink()
|
|
|
|
sim_framework_path.symlink_to(
|
|
|
|
orig_sim_framework_path.relative_to(
|
|
|
|
sim_framework_path.parent, walk_up=True
|
|
|
|
)
|
|
|
|
)
|
|
|
|
print(" done")
|
|
|
|
else:
|
|
|
|
print(" Using pre-existing iOS framework.")
|
2024-12-09 14:39:11 +08:00
|
|
|
|
|
|
|
for app_src in apps:
|
2025-08-22 08:30:58 +02:00
|
|
|
print(f" Installing app {app_src.name!r}...", end="")
|
2024-12-09 14:39:11 +08:00
|
|
|
app_target = target / f"iOSTestbed/app/{app_src.name}"
|
|
|
|
if app_target.is_dir():
|
|
|
|
shutil.rmtree(app_target)
|
|
|
|
shutil.copytree(app_src, app_target)
|
2024-12-12 23:17:58 +01:00
|
|
|
print(" done")
|
2024-12-09 14:39:11 +08:00
|
|
|
|
2024-12-12 23:17:58 +01:00
|
|
|
print(f"Successfully cloned testbed: {target.resolve()}")
|
2024-12-09 14:39:11 +08:00
|
|
|
|
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
def update_test_plan(testbed_path, args):
|
|
|
|
# Modify the test plan to use the requested test arguments.
|
|
|
|
test_plan_path = testbed_path / "iOSTestbed.xctestplan"
|
|
|
|
with test_plan_path.open("r", encoding="utf-8") as f:
|
|
|
|
test_plan = json.load(f)
|
2024-12-09 14:39:11 +08:00
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
test_plan["defaultOptions"]["commandLineArgumentEntries"] = [
|
|
|
|
{"argument": arg} for arg in args
|
|
|
|
]
|
2024-12-09 14:39:11 +08:00
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
with test_plan_path.open("w", encoding="utf-8") as f:
|
|
|
|
json.dump(test_plan, f, indent=2)
|
2024-12-09 14:39:11 +08:00
|
|
|
|
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
def run_testbed(simulator: str | None, args: list[str], verbose: bool = False):
|
2024-12-09 14:39:11 +08:00
|
|
|
location = Path(__file__).parent
|
2025-08-22 08:30:58 +02:00
|
|
|
print("Updating test plan...", end="")
|
|
|
|
update_test_plan(location, args)
|
|
|
|
print(" done.")
|
2025-02-28 03:48:46 +01:00
|
|
|
|
2025-04-29 23:59:21 +02:00
|
|
|
if simulator is None:
|
2025-08-22 08:30:58 +02:00
|
|
|
simulator = select_simulator_device()
|
|
|
|
print(f"Running test on {simulator}")
|
2024-12-09 14:39:11 +08:00
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
xcode_test(location, simulator=simulator, verbose=verbose)
|
2024-12-09 14:39:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
description=(
|
|
|
|
"Manages the process of testing a Python project in the iOS simulator."
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
subcommands = parser.add_subparsers(dest="subcommand")
|
|
|
|
|
|
|
|
clone = subcommands.add_parser(
|
|
|
|
"clone",
|
|
|
|
description=(
|
|
|
|
"Clone the testbed project, copying in an iOS Python framework and"
|
|
|
|
"any specified application code."
|
|
|
|
),
|
|
|
|
help="Clone a testbed project to a new location.",
|
|
|
|
)
|
|
|
|
clone.add_argument(
|
|
|
|
"--framework",
|
|
|
|
help=(
|
|
|
|
"The location of the XCFramework (or simulator-only slice of an "
|
|
|
|
"XCFramework) to use when running the testbed"
|
|
|
|
),
|
|
|
|
)
|
|
|
|
clone.add_argument(
|
|
|
|
"--app",
|
|
|
|
dest="apps",
|
|
|
|
action="append",
|
|
|
|
default=[],
|
|
|
|
help="The location of any code to include in the testbed project",
|
|
|
|
)
|
|
|
|
clone.add_argument(
|
|
|
|
"location",
|
|
|
|
help="The path where the testbed will be cloned.",
|
|
|
|
)
|
|
|
|
|
|
|
|
run = subcommands.add_parser(
|
|
|
|
"run",
|
|
|
|
usage="%(prog)s [-h] [--simulator SIMULATOR] -- <test arg> [<test arg> ...]",
|
|
|
|
description=(
|
|
|
|
"Run a testbed project. The arguments provided after `--` will be "
|
|
|
|
"passed to the running iOS process as if they were arguments to "
|
|
|
|
"`python -m`."
|
|
|
|
),
|
|
|
|
help="Run a testbed project",
|
|
|
|
)
|
|
|
|
run.add_argument(
|
|
|
|
"--simulator",
|
2025-04-29 23:59:21 +02:00
|
|
|
help=(
|
2025-08-22 08:30:58 +02:00
|
|
|
"The name of the simulator to use (eg: 'iPhone 16e'). Defaults to "
|
|
|
|
"the most recently released 'entry level' iPhone device. Device "
|
|
|
|
"architecture and OS version can also be specified; e.g., "
|
|
|
|
"`--simulator 'iPhone 16 Pro,arch=arm64,OS=26.0'` would run on "
|
|
|
|
"an ARM64 iPhone 16 Pro simulator running iOS 26.0."
|
|
|
|
),
|
2024-12-09 14:39:11 +08:00
|
|
|
)
|
2024-12-12 23:17:58 +01:00
|
|
|
run.add_argument(
|
2025-08-22 08:30:58 +02:00
|
|
|
"-v",
|
|
|
|
"--verbose",
|
2024-12-12 23:17:58 +01:00
|
|
|
action="store_true",
|
|
|
|
help="Enable verbose output",
|
|
|
|
)
|
2024-12-09 14:39:11 +08:00
|
|
|
|
|
|
|
try:
|
|
|
|
pos = sys.argv.index("--")
|
|
|
|
testbed_args = sys.argv[1:pos]
|
|
|
|
test_args = sys.argv[pos + 1 :]
|
|
|
|
except ValueError:
|
|
|
|
testbed_args = sys.argv[1:]
|
|
|
|
test_args = []
|
|
|
|
|
|
|
|
context = parser.parse_args(testbed_args)
|
|
|
|
|
|
|
|
if context.subcommand == "clone":
|
|
|
|
clone_testbed(
|
2025-02-13 07:47:05 +01:00
|
|
|
source=Path(__file__).parent.resolve(),
|
|
|
|
target=Path(context.location).resolve(),
|
2025-08-22 08:30:58 +02:00
|
|
|
framework=Path(context.framework).resolve()
|
|
|
|
if context.framework
|
|
|
|
else None,
|
2024-12-09 14:39:11 +08:00
|
|
|
apps=[Path(app) for app in context.apps],
|
|
|
|
)
|
|
|
|
elif context.subcommand == "run":
|
|
|
|
if test_args:
|
|
|
|
if not (
|
2025-08-22 08:30:58 +02:00
|
|
|
Path(__file__).parent
|
|
|
|
/ "Python.xcframework/ios-arm64_x86_64-simulator/bin"
|
2024-12-09 14:39:11 +08:00
|
|
|
).is_dir():
|
|
|
|
print(
|
|
|
|
f"Testbed does not contain a compiled iOS framework. Use "
|
|
|
|
f"`python {sys.argv[0]} clone ...` to create a runnable "
|
|
|
|
f"clone of this testbed."
|
|
|
|
)
|
|
|
|
sys.exit(20)
|
|
|
|
|
2025-08-22 08:30:58 +02:00
|
|
|
run_testbed(
|
|
|
|
simulator=context.simulator,
|
|
|
|
verbose=context.verbose,
|
|
|
|
args=test_args,
|
2024-12-09 14:39:11 +08:00
|
|
|
)
|
|
|
|
else:
|
2025-08-22 08:30:58 +02:00
|
|
|
print(
|
|
|
|
f"Must specify test arguments (e.g., {sys.argv[0]} run -- test)"
|
|
|
|
)
|
2024-12-09 14:39:11 +08:00
|
|
|
print()
|
|
|
|
parser.print_help(sys.stderr)
|
|
|
|
sys.exit(21)
|
|
|
|
else:
|
|
|
|
parser.print_help(sys.stderr)
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main()
|