Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ build:clang --host_cxxopt="-fvisibility=hidden"

# iOS setup
build:ios --ios_multi_cpus=sim_arm64
build:ios --@rules_apple//apple/build_settings:ios_simulator_device="iPhone 16 Pro"
# The simulator device is not pinned here: which device profiles exist depends on
# the installed Xcode's iOS runtime, so any value checked in goes stale. Set it in
# .bazelrc.user, e.g.
#
# build:ios --@rules_apple//apple/build_settings:ios_simulator_device="iPhone 17"
#
# `xcrun simctl list devicetypes` lists what is available locally.

# Platform-specific options for each supported platform.
build:remote_linux_x64 --extra_execution_platforms=//platform/linux_x64
Expand Down
23 changes: 23 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ use_repo(maven, "maven")
bazel_dep(name = "rules_kotlin", version = "2.3.20")
bazel_dep(name = "rules_perl", version = "1.1.1")
bazel_dep(name = "rules_swift", version = "4.0.0-rc2")

# Swift Package Manager deps, used by //tools/simulator_manager.
bazel_dep(name = "rules_swift_package_manager", version = "1.22.0")

swift_deps = use_extension(
"@rules_swift_package_manager//:extensions.bzl",
"swift_deps",
)
swift_deps.from_package(
declare_swift_deps_info = True,
resolved = "//tools/simulator_manager:Package.resolved",
swift = "//tools/simulator_manager:Package.swift",
)
use_repo(
swift_deps,
"swift_deps_info",
"swift_package",
"swiftpkg_shellout",
"swiftpkg_swift_argument_parser",
"swiftpkg_swift_nio",
"swiftpkg_swift_nio_extras",
)

bazel_dep(name = "rules_nodejs", version = "6.7.4")
bazel_dep(name = "aspect_rules_ts", version = "3.8.10")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
Expand Down
900 changes: 766 additions & 134 deletions MODULE.bazel.lock

Large diffs are not rendered by default.

124 changes: 123 additions & 1 deletion ios/BUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
load("@bazel_skylib//rules:build_test.bzl", "build_test")
load("@rules_apple//apple:ios.bzl", "ios_application", "ios_ui_test", "ios_unit_test")
load(
"@rules_apple//apple/testing/default_runner:ios_xctestrun_runner.bzl",
"ios_xctestrun_runner",
)
load("@rules_swift//swift:swift.bzl", "swift_library")

# Leases each test its own simulator from //tools/simulator_manager, so
# concurrent actions on one worker never share a device.
ios_xctestrun_runner(
name = "ios_leased_simulator_runner",
clean_up_simulator_action = "//tools/simulator_manager:release_simulator",
create_simulator_action = "//tools/simulator_manager:lease_simulator",
random = False,
# reuse_simulator doubles as the exclusivity toggle for the lease: opting out
# of reuse means no other test shares this device.
reuse_simulator = False,
)

# Non-exclusive leases: concurrent tests may share one simulator. Sharing is
# opportunistic, not bounded — a request only joins a device that is already
# active, so tests dispatched simultaneously can still each get their own.
ios_xctestrun_runner(
name = "ios_shared_simulator_runner",
clean_up_simulator_action = "//tools/simulator_manager:release_simulator",
create_simulator_action = "//tools/simulator_manager:lease_simulator",
random = False,
reuse_simulator = True,
)

swift_library(
name = "HelloAppLibrary",
srcs = ["HelloApp.swift"],
Expand Down Expand Up @@ -49,7 +76,102 @@ swift_library(
ios_ui_test(
name = "HelloAppUITest",
minimum_os_version = "26.0",
runner = "@rules_apple//apple/testing/default_runner:ios_xctestrun_ordered_runner",
runner = ":ios_leased_simulator_runner",
test_host = ":HelloApp",
deps = [":HelloAppUITestLib"],
exec_properties = {
"sandboxAllowed": "False",
},
)

swift_library(
name = "HelloConcurrentTestLib",
testonly = True,
srcs = ["HelloConcurrentTest.swift"],
module_name = "HelloConcurrentTest",
)

# Exercises concurrent leasing: run with --runs_per_test to get several
# simultaneous leases from one target. Unlike HelloAppUITest this has no test
# host, so it takes rules_apple's `simctl spawn` path rather than `xcodebuild`.
#
# bazel test --config=ios --runs_per_test=3 //ios:HelloConcurrentTest
#
# Each run's log reports its simulator UDID; no two concurrent runs should match.
# Manual because it is sensitive to how much memory the host has — see the
# simulator_manager README.
ios_unit_test(
name = "HelloConcurrentTest",
minimum_os_version = "26.0",
runner = ":ios_leased_simulator_runner",
tags = ["manual"],
timeout = "long",
deps = [":HelloConcurrentTestLib"],
)

# Same source, but leasing non-exclusively: several concurrent runs should
# collapse onto fewer devices. Measured on a 16 GiB worker, 8 runs shared 2
# simulators.
ios_unit_test(
name = "HelloSharedSimulatorTest",
minimum_os_version = "26.0",
runner = ":ios_shared_simulator_runner",
tags = ["manual"],
timeout = "long",
deps = [":HelloConcurrentTestLib"],
)

swift_library(
name = "HelloDyingHostTestLib",
testonly = True,
srcs = ["HelloDyingHostTest.swift"],
module_name = "HelloDyingHostTest",
)

# Kills its own test host to check whether that alone produces the
# "doesn't have a simulator leased" warning. Hosted, so the lease is held by the
# rules_apple runner script rather than the test host -- see the test source.
#
# bazel test --config=ios --config=opal --config=remote_macos_arm64 \
# --test_env=SIMULATOR_MANAGER_DEATH_MODE=signal //ios:HelloDyingHostTest
ios_unit_test(
name = "HelloDyingHostTest",
minimum_os_version = "26.0",
runner = ":ios_leased_simulator_runner",
tags = ["manual"],
test_host = ":HelloApp",
deps = [":HelloDyingHostTestLib"],
exec_properties = {
"sandboxAllowed": "False",
},
)

swift_library(
name = "HelloHostedSnapshotTestLib",
testonly = True,
srcs = ["HelloHostedSnapshotTest.swift"],
module_name = "HelloHostedSnapshotTest",
)

# Hosted so rules_apple drives it with `xcodebuild -destination id=`, and leasing
# exclusively so each concurrent action gets its own device.
# Run with a high --runs_per_test to put more concurrent actions on a worker than it has
# simulators:
#
# bazel test --config=ios --config=opal --config=remote_macos_arm64 \
# --runs_per_test=8 //ios:HelloHostedSnapshotTest
#
# Manual for the same reason as HelloConcurrentTest: sensitive to worker memory.
ios_unit_test(
name = "HelloHostedSnapshotTest",
minimum_os_version = "26.0",
runner = ":ios_leased_simulator_runner",
tags = ["manual"],
test_host = ":HelloApp",
timeout = "long",
deps = [":HelloHostedSnapshotTestLib"],
exec_properties = {
"sandboxAllowed": "False",
},
)

17 changes: 17 additions & 0 deletions ios/HelloConcurrentTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import XCTest

/// Checks that concurrent tests each lease their own simulator.
///
/// Unlike HelloAppConcurrentUITest this has no test host, so rules_apple runs it
/// via `simctl spawn` rather than `xcodebuild`.
///
/// Several targets share this source; each logs its UDID, and no two should
/// match. The sleep holds the lease long enough for the runs to actually overlap.
class HelloConcurrentTest: XCTestCase {
func testHoldsItsOwnSimulator() {
let udid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "unknown"
print("CONCURRENT_UNIT_TEST_SIMULATOR_UDID=\(udid)")
XCTAssertEqual(1 + 1, 2)
Thread.sleep(forTimeInterval: 20)
}
}
48 changes: 48 additions & 0 deletions ios/HelloDyingHostTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Darwin
import XCTest

/// Kills its own test host mid-suite, to find out whether a dying test process is
/// enough to produce a "PID N doesn't have a simulator leased" warning.
///
/// The warning means the daemon had no lease for the PID that tried to release
/// one, which happens when the daemon already reclaimed the lease via its
/// leaser-exit listener. The open question is *whose* death triggers that: the
/// lease is keyed on the rules_apple runner script's PID
/// (`XCTESTRUN_RUNNER_PID`), and that script outlives the test host, so killing
/// the host may leave the lease untouched.
///
/// `SIMULATOR_MANAGER_DEATH_MODE` selects how to die.
///
/// * `signal` -- SIGKILL, matching "Test crashed with signal kill before
/// establishing connection".
/// * `exit` -- a zero exit, matching "The test runner exited with code 0 before
/// establishing connection".
/// * `runner` -- SIGKILL to the rules_apple runner script, i.e. the process that
/// actually holds the lease. `signal` and `exit` only kill the test host,
/// which the script outlives, so the script still releases its own live lease
/// successfully. This mode targets the lease holder itself.
///
/// Unset means don't die, so the target is also a control.
class HelloDyingHostTest: XCTestCase {
/// Named to sort before `testDies`: XCTest runs cases alphabetically, so this
/// establishes that the suite was really running before the host went away.
func testAaaRunsBeforeDying() {
let udid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "unknown"
print("DYING_HOST_SIMULATOR_UDID=\(udid)")
print("DYING_HOST_PID=\(getpid())")
}

func testDies() {
let mode = ProcessInfo.processInfo.environment["SIMULATOR_MANAGER_DEATH_MODE"] ?? ""
print("DYING_HOST_DEATH_MODE=\(mode)")

switch mode {
case "signal":
kill(getpid(), SIGKILL)
case "exit":
exit(0)
default:
break
}
}
}
56 changes: 56 additions & 0 deletions ios/HelloHostedSnapshotTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import UIKit
import XCTest

/// The distinguishing features, versus HelloConcurrentTest:
///
/// * it has a test host, so rules_apple drives it with `xcodebuild` and a
/// `-destination id=` rather than `simctl spawn`. `xcodebuild` installs the
/// host onto the device, which is the step that reports `No matching device`
/// when a device is removed underneath it.
/// * it renders views, so it needs a booted UI stack rather than just a process
/// to run in. A test host that dies mid-render produces the "Restarting after
/// unexpected exit, crash, or test timeout" line.
///
/// Many small cases rather than one long sleep: the failures this is meant to
/// provoke land between cases -- on install, or on a device pulled out from under
/// a suite that is partway through -- so the suite needs many opportunities to be
/// interrupted.
class HelloHostedSnapshotTest: XCTestCase {
func testLogsItsDevice() {
let udid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "unknown"
print("HOSTED_SNAPSHOT_SIMULATOR_UDID=\(udid)")
}

/// Renders a view hierarchy repeatedly, approximating snapshot work: enough
/// UIKit and memory traffic per case to make contention on a shared device
/// observable, without asserting on images we would then have to record.
func testRendersRepeatedly() {
for iteration in 0 ..< 40 {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
view.backgroundColor = iteration.isMultiple(of: 2) ? .systemBlue : .systemRed

let label = UILabel(frame: view.bounds.insetBy(dx: 20, dy: 20))
label.text = "iteration \(iteration)"
label.numberOfLines = 0
view.addSubview(label)

let renderer = UIGraphicsImageRenderer(bounds: view.bounds)
let image = renderer.image { context in
view.layer.render(in: context.cgContext)
}
XCTAssertGreaterThan(image.size.width, 0)
}
}

func testPresentsViewControllers() {
for _ in 0 ..< 10 {
let controller = UIViewController()
controller.view.backgroundColor = .white
let child = UIViewController()
controller.addChild(child)
controller.view.addSubview(child.view)
child.didMove(toParent: controller)
XCTAssertNotNil(controller.view)
}
}
}
Loading
Loading