Skip to content

Commit 26e7cb5

Browse files
authored
test(ios): speed up fixtures and run native regressions nightly (#3824)
* test(ios): reuse fixtures across native regression assertions * test(ios): observe interruption opt-out during native dispatch * docs: record native fixture reuse measurements and mutation proof * perf(ios): avoid polling fixtures that already exist * ci(ios): keep native fault regressions nightly and check shared branches on PRs * test(ios): count real focus taps and retain dropped-tap recovery * test(ios): use immediate fixture checks in the upstream latency regression * test(ios): remove the duplicate drag input from the preflight table * chore(gates): describe the generated iOS branch checks * docs: record full XCTest profiling and nightly coverage proof
1 parent a661e43 commit 26e7cb5

26 files changed

Lines changed: 657 additions & 309 deletions

‎.github/actions/smoke-ios-run/action.yml‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ runs:
4747
with:
4848
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
4949
preferred-device-name: iPhone 17 Pro
50-
- name: Run targeted iOS runner XCTest regressions
50+
- name: Run iOS runner platform checks
5151
id: ios-xctest
5252
if: steps.xctest-impact.outputs.run != 'false'
5353
run: |
@@ -66,13 +66,11 @@ runs:
6666
-destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \
6767
-resultBundlePath "$GITHUB_WORKSPACE/test/artifacts/ios-runner-regressions.xcresult" \
6868
"${IOS_PR_TEST_ARGS[@]}" 2>&1 | tee /tmp/agent-device-runner-regressions.log
69-
node --input-type=module -e '
70-
import { readFileSync } from "node:fs";
71-
const log = readFileSync("/tmp/agent-device-runner-regressions.log", "utf8");
72-
if (!/\] AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED$/m.test(log)) {
73-
throw new Error("Optional observation ended the runner test before its wait completed");
74-
}
75-
'
69+
xcrun xcresulttool get test-results summary \
70+
--path "$GITHUB_WORKSPACE/test/artifacts/ios-runner-regressions.xcresult" \
71+
--compact > "$GITHUB_WORKSPACE/test/artifacts/ios-runner-regressions-summary.json"
72+
RESULT_SUMMARY_PATH="$GITHUB_WORKSPACE/test/artifacts/ios-runner-regressions-summary.json" \
73+
XCTEST_LANE=pr node --experimental-strip-types scripts/xctest-run-summary.ts
7674
shell: bash
7775
- name: Preflight iOS runner through public CLI
7876
run: |

‎.github/workflows/xctest-nightly.yml‎

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,9 @@
11
name: XCTest Nightly
22

3-
# The full iOS runner XCTest suite (#1781 A7). The iOS PR phase in apple.yml selects a source-derived
4-
# subset of the target's methods in an `-only-testing:` list; everything outside that list ran
5-
# nowhere at all. This lane drops the filter and runs the test plan whole, skipping only
6-
# `testCommand` — which is the runner's server entry point rather than a test (see the step
7-
# below).
8-
#
9-
# No count is quoted here on purpose. `pnpm check:xctest-selection` prints the live split
10-
# (declared / PR-selected / skipped / nightly-only) and is the only place those numbers are
11-
# derived rather than remembered. A hand-copied figure in a comment is the same class of
12-
# defect this lane exists to close: between this file being written and first review, the
13-
# suite gained three methods and the PR list gained five entries, so every number originally
14-
# written here was already wrong.
15-
#
16-
# Under the lane rule in #1781:
17-
#
18-
# - Catches: regressions in the simulator-only tests (guard `… && os(iOS)`: they launch
19-
# the host app, route through SpringBoard, or assert an iOS-only branch) that apple.yml's
20-
# PR list leaves out. Since the A7 classification the pure-decision tests also run on
21-
# the macOS phase on every PR (apple.yml), so this lane's
22-
# unique reach is the simulator-only remainder — `pnpm check:xctest-selection` prints
23-
# both numbers. `testCommand` is skipped, and the tests guarded to macOS/tvOS only
24-
# cannot execute on this lane's iOS Simulator destination (the host lane runs them).
25-
# - Evidence: the first execution found #1812 in the dark set (2 of 155 red, both real);
26-
# the other ~114 never-run tests all passed.
27-
# - Cost: this is a public repository, so nothing here is billed. The real cost is one macOS
28-
# concurrency slot per night. The Apple runner build shares apple.yml's cache (identical
29-
# inputs and build variant), but scheduled runs only see DEFAULT-BRANCH caches, so the hit
30-
# depends on apple.yml's `push: main` run having populated the cache at the same source
31-
# hash — on a miss this lane also pays a full runner build. Adds nothing to any PR.
32-
# - Kill criterion: what ONLY this lane reaches is 11 tests / ~42 s of simulator time
33-
# (measured from the first night's xcresult) — the app-launching, SpringBoard-probing
34-
# class. Once apple.yml runs the whole iOS-compiled bundle instead of its selected
35-
# list — the follow-up stacked on the classification — every test reaches a PR lane and
36-
# this lane goes. That trade is not free: those 11 are the flakiest tests in the suite,
37-
# and they would land on a blocking 15-minute gate that is already cancelled ~19% of the
38-
# time, so measure their flake rate here before moving them.
39-
#
40-
# Scheduled + manual only. It is deliberately NOT a PR gate: apple.yml is already over half of
41-
# this repo's CI spend (#1781 A9), and moving the unrun set onto it would make that worse for
42-
# tests whose flakiness on a shared runner is unknown.
43-
#
44-
# Note on `concurrency` below: it is keyed on `github.ref`, so a manual dispatch on the same
45-
# ref cancels an in-flight scheduled run. That is the intended trade — an operator asking for
46-
# a run wants the fresh one — but it means "dispatch while the nightly is running" loses the
47-
# nightly's result rather than queueing behind it.
3+
# Run all native iOS regressions nightly and on manual dispatch. PRs keep the host
4+
# decision suite, shared platform-branch checks, and public CLI E2E smoke.
5+
# Native fault injection and identity checks remain here; healthy E2E does not
6+
# replace those contracts. The source-derived count guards against a zero-test run.
487

498
on:
509
schedule:
@@ -63,13 +22,6 @@ jobs:
6322
full-suite:
6423
name: iOS Runner Full XCTest Suite
6524
runs-on: macos-26
66-
# Measured, not guessed. A full local run of this exact command on matching toolchain
67-
# (Xcode 26.2, iOS 26.2 Simulator) executed 154 tests in 120s wall — the suite itself is
68-
# ~2 minutes, not the bulk of the job. What dominates the budget is the runner build on a
69-
# derived-data cache MISS, plus checkout and simulator boot. the iOS phase in apple.yml has an 80-minute limit
70-
# while doing strictly more than this lane (same build and boot, plus replays, prepare,
71-
# and the fixture E2E), so half of that is generous headroom for a cache-miss night and
72-
# still caps a wedged run at 45 minutes instead of two hours of held macOS slot.
7325
timeout-minutes: 45
7426
env:
7527
# Must match apple.yml. The runtime version rides in the derived-data cache key, and
@@ -141,7 +93,14 @@ jobs:
14193
-xctestrun "$XCTESTRUN_PATH" \
14294
-destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \
14395
-skip-testing:AgentDeviceRunnerUITests/RunnerTests/testCommand \
144-
-resultBundlePath "$RESULT_BUNDLE_PATH"
96+
-resultBundlePath "$RESULT_BUNDLE_PATH" 2>&1 | tee .tmp/xctest-nightly/runner.log
97+
node --input-type=module -e '
98+
import { readFileSync } from "node:fs";
99+
const log = readFileSync(".tmp/xctest-nightly/runner.log", "utf8");
100+
if (!/\] AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED$/m.test(log)) {
101+
throw new Error("Optional observation ended the runner test before its wait completed");
102+
}
103+
'
145104
146105
# Best-effort and never the job's verdict on its own; the step below is what asserts.
147106
# `--compact` first because a red night's summary is the large one, and the job summary
@@ -166,7 +125,7 @@ jobs:
166125
# guards (XCTEST_LANE above), so a guard that compiles a file of tests out of iOS reads
167126
# as red too. The failure list is capped so a badly red night cannot blow the 1 MiB
168127
# job-summary limit and lose the headline with it.
169-
- name: Report the run and assert it executed the source-derived set
128+
- name: Report the run and assert the source-derived count
170129
if: always()
171130
run: |
172131
set -euo pipefail

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,11 @@ final class RunnerTests: XCTestCase {
156156
// `-D AGENT_DEVICE_RUNNER_UNIT_TESTS` and are classified by their `#if` guard (#1781 A7):
157157
// - `#if AGENT_DEVICE_RUNNER_UNIT_TESTS` alone: a pure runner decision (rule table,
158158
// geometry, parser, policy, journal, dispatch bookkeeping) that needs no launched app.
159-
// Runs on the macOS host lane on every PR (ci.yml, no simulator) and on the iOS lanes.
159+
// Runs on the macOS host lane on every PR and on the full iOS nightly lane.
160160
// - `… && os(iOS)` (or a nested `#if os(iOS)`): runner/XCTest semantics — launches the
161161
// host app, routes through SpringBoard, swizzles XCUIApplication, or asserts an
162-
// iOS-only branch. Simulator lanes only (ios.yml PR list, xctest-nightly.yml).
162+
// iOS-only branch. Full simulator coverage runs nightly (xctest-nightly.yml).
163+
// - Shared methods with platform-dependent bodies also run on the PR simulator lane.
163164
// `pnpm check:xctest-selection` derives each lane's reachable set from these guards and
164165
// fails when a declared test is reachable by no lane, so a test gated to a platform nothing
165166
// runs (the old tvOS-only pair) cannot go dark silently.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
2+
import XCTest
3+
4+
extension XCUIElement {
5+
func waitForFixtureExistence(timeout: TimeInterval) -> Bool {
6+
exists || waitForExistence(timeout: timeout)
7+
}
8+
}
9+
#endif

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertButtonChoiceTests.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ extension RunnerTests {
131131
invalidateCachedTarget(reason: "unit_test_cleanup")
132132
app.terminate()
133133
}
134-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
134+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
135135
// Resolution is a pure read; a contended hosted simulator can spend far more than the 10 s
136136
// command timeout on it, so it runs under the sibling fixtures' resolution allowance.
137137
let alert = try XCTUnwrap(
@@ -160,7 +160,7 @@ extension RunnerTests {
160160
invalidateCachedTarget(reason: "unit_test_cleanup")
161161
app.terminate()
162162
}
163-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
163+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
164164
let alert = try XCTUnwrap(
165165
resolveAlert(app: app, deadline: Date().addingTimeInterval(RunnerTests.alertResolutionAllowance))
166166
)

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift‎

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ObjectiveC.runtime
55

66
private final class AlertTapDeadlineStub: NSObject {
77
static var status: RunnerTapSynthesisStatus?
8+
static var interruptionHandlingDisabled: Bool?
89

910
@objc(synthesizeTapWithApplication:resolvedWindow:x:y:deadline:errorMessage:)
1011
class func synthesizeTap(
@@ -15,7 +16,8 @@ private final class AlertTapDeadlineStub: NSObject {
1516
deadline: NSDate?,
1617
errorMessage: AutoreleasingUnsafeMutablePointer<NSString?>?
1718
) -> RunnerTapSynthesisStatus {
18-
status ?? (deadline == nil ? .succeeded : .deadlineExceeded)
19+
interruptionHandlingDisabled = (application.value(forKey: "doesNotHandleUIInterruptions") as? NSNumber)?.boolValue
20+
return status ?? (deadline == nil ? .succeeded : .deadlineExceeded)
1921
}
2022
}
2123
#endif
@@ -49,7 +51,7 @@ extension RunnerTests {
4951
invalidateCachedTarget(reason: "unit_test_cleanup")
5052
app.terminate()
5153
}
52-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
54+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
5355
let alert = try resolveAlertBeforeTheCommand()
5456
let response = handleAlert(alert, action: "accept", deadline: .distantPast)
5557
XCTAssertFalse(response.ok)
@@ -67,7 +69,7 @@ extension RunnerTests {
6769
invalidateCachedTarget(reason: "unit_test_cleanup")
6870
app.terminate()
6971
}
70-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
72+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
7173
let alert = try resolveAlertBeforeTheCommand()
7274
alertButtonHittabilityProbeOverrideForTesting = { probeDeadline in
7375
while Date() < probeDeadline {
@@ -90,7 +92,7 @@ extension RunnerTests {
9092
invalidateCachedTarget(reason: "unit_test_cleanup")
9193
app.terminate()
9294
}
93-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
95+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
9496
let alert = try resolveAlertBeforeTheCommand()
9597
let button = try XCTUnwrap(alert.buttons.first { $0.label == "OK" })
9698
let frame = button.frame
@@ -110,7 +112,7 @@ extension RunnerTests {
110112
invalidateCachedTarget(reason: "unit_test_cleanup")
111113
app.terminate()
112114
}
113-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
115+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
114116
let alert = try resolveAlertBeforeTheCommand()
115117
let button = try XCTUnwrap(alert.buttons.first { $0.label == "OK" })
116118
let frame = button.frame
@@ -121,6 +123,7 @@ extension RunnerTests {
121123
method_setImplementation(method, method_getImplementation(stub))
122124
defer {
123125
AlertTapDeadlineStub.status = nil
126+
AlertTapDeadlineStub.interruptionHandlingDisabled = nil
124127
method_setImplementation(method, original)
125128
}
126129

@@ -150,7 +153,7 @@ extension RunnerTests {
150153
invalidateCachedTarget(reason: "unit_test_cleanup")
151154
app.terminate()
152155
}
153-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
156+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
154157
let alert = try resolveAlertBeforeTheCommand()
155158

156159
let response = handleAlert(alert, action: "accept", deadline: Date().addingTimeInterval(RunnerTests.alertActivationDeadline))
@@ -165,7 +168,7 @@ extension RunnerTests {
165168
}
166169

167170
@MainActor
168-
func testAlertActivationDoesNotConsultInterruptionHandlingWithANotificationBanner() throws {
171+
func testAlertActivationConfirmsBeforeDeadlineWithoutHandlingTheNotificationBanner() throws {
169172
try withNotificationBannerAlert { alert in
170173
var consultedInterruptions: [String] = []
171174
let monitor = addUIInterruptionMonitor(withDescription: "alert activation banner") { element in
@@ -174,24 +177,41 @@ extension RunnerTests {
174177
}
175178
defer { removeUIInterruptionMonitor(monitor) }
176179

177-
_ = handleAlert(alert, action: "accept", deadline: Date().addingTimeInterval(RunnerTests.alertActivationDeadline))
178-
179-
XCTAssertEqual(consultedInterruptions, [], "alert activation consulted XCTest's interruption handling")
180-
}
181-
}
182-
183-
@MainActor
184-
func testAlertActivationConfirmsBeforeDeadlineWithANotificationBanner() throws {
185-
try withNotificationBannerAlert { alert in
180+
try assertAlertActivationDisablesInterruptionHandling(alert)
186181
let deadline = Date().addingTimeInterval(RunnerTests.alertActivationDeadline)
187182
let response = handleAlert(alert, action: "accept", deadline: deadline)
188183

184+
XCTAssertEqual(consultedInterruptions, [], "alert activation consulted XCTest's interruption handling")
189185
XCTAssertTrue(response.ok, String(describing: response.error))
190186
XCTAssertLessThan(Date(), deadline)
191187
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")
192188
}
193189
}
194190

191+
@MainActor
192+
private func assertAlertActivationDisablesInterruptionHandling(_ alert: RunnerAlert) throws {
193+
let button = try XCTUnwrap(alert.buttons.first { $0.label == "OK" })
194+
let frame = button.frame
195+
let selector = NSSelectorFromString("synthesizeTapWithApplication:resolvedWindow:x:y:deadline:errorMessage:")
196+
let method = try XCTUnwrap(class_getClassMethod(RunnerSynthesizedGesture.self, selector))
197+
let stub = try XCTUnwrap(class_getClassMethod(AlertTapDeadlineStub.self, selector))
198+
let original = method_getImplementation(method)
199+
AlertTapDeadlineStub.status = .deadlineExceeded
200+
AlertTapDeadlineStub.interruptionHandlingDisabled = nil
201+
method_setImplementation(method, method_getImplementation(stub))
202+
defer {
203+
AlertTapDeadlineStub.status = nil
204+
AlertTapDeadlineStub.interruptionHandlingDisabled = nil
205+
method_setImplementation(method, original)
206+
}
207+
208+
let outcome = activateAlertButton(
209+
alert, button: button, action: "accept", frame: frame, deadline: .distantFuture
210+
)
211+
XCTAssertNil(outcome)
212+
XCTAssertEqual(AlertTapDeadlineStub.interruptionHandlingDisabled, true)
213+
}
214+
195215
@MainActor
196216
private func withNotificationBannerAlert(_ assertions: (RunnerAlert) throws -> Void) throws {
197217
app.launchArguments = ["--agent-device-alert-replacement-regression", "--agent-device-alert-banner"]
@@ -204,7 +224,7 @@ extension RunnerTests {
204224
_ = banner.waitForNonExistence(timeout: 15)
205225
}
206226
try acceptNotificationAuthorizationUntilAlertAppears()
207-
XCTAssertTrue(banner.waitForExistence(timeout: appExistenceTimeout), "the fixture keeps a banner up")
227+
XCTAssertTrue(banner.waitForFixtureExistence(timeout: appExistenceTimeout), "the fixture keeps a banner up")
208228
let alert = try resolveAlertBeforeTheCommand()
209229

210230
try assertions(alert)
@@ -255,7 +275,7 @@ extension RunnerTests {
255275
invalidateCachedTarget(reason: "unit_test_cleanup")
256276
app.terminate()
257277
}
258-
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
278+
XCTAssertTrue(app.alerts.firstMatch.waitForFixtureExistence(timeout: appExistenceTimeout))
259279
let original = try resolveAlertBeforeTheCommand()
260280
alertResolutionOverrideForTesting = { _ in original }
261281
let timeoutMs = Int(RunnerTests.alertActivationDeadline * 1000)

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ extension RunnerTests {
8787
private func launchCrowdedScreen(extraArguments: [String]) {
8888
app.launchArguments = ["--agent-device-crowded-screen"] + extraArguments
8989
app.launch()
90-
XCTAssertTrue(app.staticTexts["agent-device-crowded-row-499"].waitForExistence(timeout: appExistenceTimeout))
90+
XCTAssertTrue(app.staticTexts["agent-device-crowded-row-499"].waitForFixtureExistence(timeout: appExistenceTimeout))
9191
}
9292

9393
@MainActor

0 commit comments

Comments
 (0)