Skip to content
Merged
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
6 changes: 2 additions & 4 deletions .github/workflows/xctest-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,8 @@ jobs:
# (packages/platform-apple/src/runner/runner-session.ts always passes it as the sole
# `-only-testing:`). It compiles unconditionally — the `#if AGENT_DEVICE_RUNNER_UNIT_TESTS`
# block ends well above it — so an unfiltered run reaches it in alphabetical order and
# hangs the job until `timeout-minutes`. Its one escape hatch,
# AGENT_DEVICE_RUNNER_NOOP_STARTUP, is an environment variable, and the comment above
# the flag records that env plumbing into a simulator test process is not dependable;
# `-skip-testing:` is the lever that works from here.
# hangs the job until `timeout-minutes`. The method has no in-process escape hatch, so
# `-skip-testing:` is the lever that keeps it out of this run.
#
# A typo in that identifier silently re-arms the hang, so
# `pnpm check:xctest-selection` validates `-skip-testing:` exactly like `-only-testing:`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,10 @@ extension RunnerTests {
static func privateAXCustomActionCoverage(_ raw: Any?) -> SnapshotCustomActionCoverage? {
guard let coverage = raw as? [String: Any],
let read = (coverage[RunnerAXSnapshotCustomActionsReadKey] as? NSNumber)?.intValue,
let candidates = (coverage[RunnerAXSnapshotCustomActionsCandidatesKey] as? NSNumber)?.intValue
let candidates = (coverage[RunnerAXSnapshotCustomActionsCandidatesKey] as? NSNumber)?.intValue,
let truncated = (coverage[RunnerAXSnapshotCustomActionsTruncatedKey] as? NSNumber)?.intValue,
let blocked = (coverage[RunnerAXSnapshotCustomActionsBlockedKey] as? NSNumber)?.boolValue
else { return nil }
// read/candidates are the ratio and must both be present; the truncation
// count is additive, so an older bridge that omits it reads as zero rather
// than voiding the whole coverage.
let truncated =
(coverage[RunnerAXSnapshotCustomActionsTruncatedKey] as? NSNumber)?.intValue ?? 0
let blocked = (coverage[RunnerAXSnapshotCustomActionsBlockedKey] as? NSNumber)?.boolValue ?? false
return SnapshotCustomActionCoverage(
read: read, candidates: candidates, truncated: truncated, blocked: blocked)
}
Expand Down Expand Up @@ -513,19 +509,28 @@ extension RunnerTests {
/// The disclosure only exists if the counts survive the bridge boundary, and
/// "did not ask" must stay distinguishable from "read none".
func testCustomActionCoverageParsesOnlyCompletePairs() {
let coverage = Self.privateAXCustomActionCoverage([
let complete: [String: Any] = [
RunnerAXSnapshotCustomActionsReadKey: 12,
RunnerAXSnapshotCustomActionsCandidatesKey: 19,
])
RunnerAXSnapshotCustomActionsTruncatedKey: 2,
RunnerAXSnapshotCustomActionsBlockedKey: true,
]
let coverage = Self.privateAXCustomActionCoverage(complete)
XCTAssertEqual(coverage?.read, 12)
XCTAssertEqual(coverage?.candidates, 19)
XCTAssertEqual(coverage?.truncated, 2)
XCTAssertEqual(coverage?.blocked, true)

// Absent key = the capture never asked; it must not read as (0, 0), which
// would warn "0 of 0" on every default capture.
XCTAssertNil(Self.privateAXCustomActionCoverage(nil))
// A half-present pair cannot express a ratio, so it is dropped whole.
XCTAssertNil(
Self.privateAXCustomActionCoverage([RunnerAXSnapshotCustomActionsReadKey: 12]))
// The bridge in this target always writes all four keys, so a partial
// dictionary is malformed and is dropped whole.
for key in complete.keys {
var partial = complete
partial.removeValue(forKey: key)
XCTAssertNil(Self.privateAXCustomActionCoverage(partial), "missing \(key)")
}
}

/// The AX call cannot be cancelled once issued, so the read deadline frees
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,7 @@ extension RunnerTests {
}
#endif

func testExecuteDispatchedReturnsBusyBeforeMainThreadFastPath() throws {
func testDispatchReturnsBusyBeforeQueueingMainThreadWork() throws {
let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-busy"}"#)
abandonedMainThreadWorkCount = 1
abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -2)
Expand All @@ -833,14 +833,14 @@ extension RunnerTests {
abandonedMainThreadWorkSince = nil
}

let response = try executeDispatched(command: command)
let response = try execute(command: command)

XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "RUNNER_BUSY")
XCTAssertTrue(response.error?.message.contains("previous command") == true)
}

func testExecuteDispatchedReturnsWedgedBeforeMainThreadFastPath() throws {
func testDispatchReturnsWedgedBeforeQueueingMainThreadWork() throws {
let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-wedged"}"#)
abandonedMainThreadWorkCount = 1
abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -(mainThreadWedgeThreshold + 1))
Expand All @@ -849,7 +849,7 @@ extension RunnerTests {
abandonedMainThreadWorkSince = nil
}

let response = try executeDispatched(command: command)
let response = try execute(command: command)

XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "RUNNER_WEDGED")
Expand Down Expand Up @@ -936,15 +936,32 @@ extension RunnerTests {
#endif

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
/// Routes `command` through the transport's inline and queued paths. The calling test's main
/// thread serves the command's main-thread work while it waits.
func execute(command: Command) throws -> Response {
if command.command == .status {
return executeStatus(command: command)
dispatchPrecondition(condition: .onQueue(.main))
if let response = inlineResponse(for: command) {
return response
}
if command.command == .uptime {
return executeUptime()
final class ResultBox {
var result: Result<Response, Error>?
}
commandJournal.accept(command: command)
return try executeAccepted(command: command)
let box = ResultBox()
let executed = XCTestExpectation(description: "\(command.command.rawValue) executed off main")
enqueueAccepted(command: command) { result in
box.result = result
executed.fulfill()
}
guard XCTWaiter.wait(for: [executed], timeout: mainThreadExecutionTimeout + 5) == .completed,
let result = box.result
else {
throw NSError(
domain: RunnerErrorDomain.general,
code: RunnerErrorCode.commandReturnedNoResponse,
userInfo: [NSLocalizedDescriptionKey: "command did not finish on the command queue"]
)
}
return try result.get()
}
#endif

Expand Down Expand Up @@ -1063,14 +1080,6 @@ extension RunnerTests {
let alertDeadline = command.command == .alert
? Date().addingTimeInterval(Self.alertCommandTimeout(timeoutMs: command.timeoutMs))
: nil
if Thread.isMainThread {
let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(command)
return try executeOnMainSafely(
command: command,
alertDeadline: alertDeadline,
routeToSpringboard: routeToSpringboard
)
}
// Resolve this before the command's outer main-thread block. If the bounded probe abandons
// slow XCTest enumeration, return the established recoverable response instead of queueing
// command preparation behind work that may outlive the 30-second command watchdog.
Expand Down Expand Up @@ -1300,7 +1309,7 @@ extension RunnerTests {
private func executeSnapshotPrepared(
command: Command,
activeApp: XCUIApplication,
systemSurface: SystemSurfaceHost? = nil
systemSurface: SystemSurfaceHost?
) throws -> Response {
let options = Self.presentationOptions(from: command)
do {
Expand Down Expand Up @@ -1332,10 +1341,6 @@ extension RunnerTests {
}

private func setNeedsPostSnapshotInteractionDelay() {
if Thread.isMainThread {
needsPostSnapshotInteractionDelay = true
return
}
guard !hasAbandonedMainThreadWork() else {
NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED")
return
Expand All @@ -1354,10 +1359,6 @@ extension RunnerTests {
}

private func invalidateCachedTargetAfterSnapshotFailure() {
if Thread.isMainThread {
invalidateCachedTarget(reason: "ax_snapshot_failure")
return
}
// Abandoned work ahead of this hop cannot be cancelled: queue the drop behind it without
// waiting, so the failed capture answers now and the next command still finds the target gone.
guard !hasAbandonedMainThreadWork() else {
Expand Down Expand Up @@ -1633,7 +1634,8 @@ extension RunnerTests {
clearRememberedTextEntryTap()
}
switch command.command {
case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime:
case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime,
.snapshot:
return Response(
ok: false,
error: ErrorPayload(
Expand Down Expand Up @@ -2049,8 +2051,6 @@ extension RunnerTests {
return Response(ok: false, error: ErrorPayload(message: "readText did not resolve text"))
}
return Response(ok: true, data: DataPayload(text: text))
case .snapshot:
return try executeSnapshotPrepared(command: command, activeApp: activeApp)
case .screenshot:
#if os(macOS)
// macOS keeps the app-targeted capture behavior for window-level screenshots.
Expand Down Expand Up @@ -2116,11 +2116,10 @@ extension RunnerTests {
inlineScreenshot: command.inlineScreenshot == true
)
#endif
case .back, .backInApp:
case .backInApp:
switch tapInAppBackControl(app: activeApp) {
case .performed:
let message = command.command == .back ? "back" : "backInApp"
return Response(ok: true, data: DataPayload(message: message))
return Response(ok: true, data: DataPayload(message: "backInApp"))
case .unavailable:
return Response(
ok: false,
Expand Down Expand Up @@ -2612,15 +2611,6 @@ extension RunnerTests {
}
#endif
let probeDeadline = Date().addingTimeInterval(systemModalProbeBudget)
// `runMainThreadWork` executes inline for a main-thread caller, so that path cannot use its
// timeout machinery. Direct main-thread dispatch keeps the prior synchronous modal check;
// normal off-main command dispatch uses the bounded probe and post-probe busy recovery.
if Thread.isMainThread {
return firstBlockingSystemModal(
in: springboard,
deadline: probeDeadline
) != nil
}
return boundedBlockingSystemAlertSnapshot(
deadline: probeDeadline
) != nil
Expand Down Expand Up @@ -2668,15 +2658,12 @@ extension RunnerTests {
let textEntryMode = resolveTextEntryMode(command)
let target: TextEntryTarget
var resolvedCoordinateContext: SynthesizedCoordinateContext?
var maestroNonHittableCoordinateFallbackUsed: Bool?
if command.allowNonHittableCoordinateFallback == true,
command.x != nil,
command.y != nil
{
// The shared runtime has already resolved this node as non-hittable and
// deliberately selected Maestro's coordinate compatibility route.
maestroNonHittableCoordinateFallbackUsed = true
}
// The shared runtime has already resolved this node as non-hittable and
// deliberately selected Maestro's coordinate compatibility route.
let maestroNonHittableCoordinateFallbackUsed: Bool? =
command.allowNonHittableCoordinateFallback == true && command.x != nil && command.y != nil
? true
: nil
let focusStartedAt = Date()
#if os(iOS)
let xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)
Expand Down Expand Up @@ -2721,28 +2708,6 @@ extension RunnerTests {
#endif
if let resolvedCoordinateTarget {
target = resolvedCoordinateTarget
} else if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
// Released daemons may still send selector-keyed type commands even though current
// daemons resolve fill selectors through the runtime tree before reaching the runner.
let match = findElement(
app: activeApp,
selectorKey: selectorKey,
selectorValue: selectorValue,
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true
)
if match.isAmbiguous {
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
}
guard let element = match.element else {
return Response(ok: false, error: ErrorPayload(code: "NO_MATCH", message: "selector did not match an element"))
}
guard isTextEntryElement(element) else {
return Response(ok: false, error: ErrorPayload(code: "INVALID_TARGET", message: "selector did not match a text input"))
}
if command.allowNonHittableCoordinateFallback == true {
maestroNonHittableCoordinateFallbackUsed = match.usedNonHittableFallback
}
target = focusTextInputForTextEntry(app: activeApp, element: element)
} else {
target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ final class RunnerCommandJournal {
case .snapshot, .screenshot:
return false
case .tap, .mouseClick, .longPress, .drag,
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .back,
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .actionButton, .keyboardDismiss, .keyboardReturn,
.alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop,
.status, .uptime, .activate, .terminate, .targetReset, .shutdown:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,4 @@ enum RunnerEnv {
}
return 0
}

static func isTruthy(_ name: String) -> Bool {
guard let raw = ProcessInfo.processInfo.environment[name] else {
return false
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,7 @@ extension RunnerTests {
}

func shouldRetryCommand(_ command: Command) -> Bool {
if RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
return false
}
return isReadOnlyCommand(command)
isReadOnlyCommand(command)
}

func shouldRetryException(_ command: Command, message: String) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ enum CommandType: String, Codable {
case readText
case snapshot
case screenshot
case back
case backInApp
case backSystem
case home
Expand Down Expand Up @@ -78,7 +77,7 @@ extension CommandType {
// classification as .drag. .desktopScroll is the macOS frame-resolve + wheel event sibling.
// .sequence is the fused multi-step gesture batch.
case .tap, .longPress, .drag, .remotePress, .type, .swipe, .scroll, .desktopScroll,
.back, .backInApp, .backSystem, .rotate, .appSwitcher,
.backInApp, .backSystem, .rotate, .appSwitcher,
.keyboardDismiss, .keyboardReturn, .sequence, .gesture:
return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,7 @@ extension RunnerTests {
in springboard: XCUIApplication,
deadline: Date = .distantFuture
) -> XCUIElement? {
let disableSafeProbe = RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
if disableSafeProbe {
return fetch()
}
return self.safeElementsQuery(fetch)
}

let alerts = queryElements {
let alerts = safeElementsQuery {
springboard.alerts.allElementsBoundByIndex
}
for alert in alerts {
Expand All @@ -92,7 +84,7 @@ extension RunnerTests {
return nil
}

let sheets = queryElements {
let sheets = safeElementsQuery {
springboard.sheets.allElementsBoundByIndex
}
for sheet in sheets {
Expand Down
Loading
Loading