Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#import <notify.h>

#import "RunnerObjCExceptionCatcher.h"
#import "RunnerAXSnapshotBridge.h"
#import "RunnerSynthesizedGesture.h"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,8 @@ extension RunnerTests {
)
}
return Response(ok: true, data: DataPayload(message: "actionButton"))
case .screenLock:
return executeScreenLockCommand()
case .keyboardDismiss:
let result = dismissKeyboard(app: activeApp)
if result.wasVisible && !result.dismissed {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ final class RunnerCommandJournal {
return false
case .tap, .mouseClick, .longPress, .drag,
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .actionButton, .keyboardDismiss, .keyboardReturn,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .actionButton, .screenLock, .keyboardDismiss, .keyboardReturn,
.alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop,
.status, .uptime, .appState, .activate, .terminate, .targetReset, .shutdown:
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum CommandType: String, Codable, CaseIterable {
case rotate
case appSwitcher
case actionButton
case screenLock
case keyboardDismiss
case keyboardReturn
case alert
Expand Down Expand Up @@ -149,6 +150,12 @@ fileprivate extension CommandTraits {
/// The runner's own lifecycle: no session app is brought forward, and no mutation is proven.
static let runnerLifecycle = CommandTraits(launchPolicy: .noApp)

/// A verified system-state mutation must not activate the session app before dispatch.
static let systemStateMutation = CommandTraits(
launchPolicy: .noApp,
convertsRecordedFailure: true
)

/// Commands hosted by the surface that already has focus, which no activation may cancel. A
/// hardware press belongs to the system rather than to the session app, and an alert answers from
/// the modal where it sits; both mutate.
Expand Down Expand Up @@ -256,6 +263,10 @@ extension Command {
case .actionButton:
return .presentedSurfaceMutation

case .screenLock:
// Unsupported platform leaves must fail before bringing the session app forward.
return .systemStateMutation

case .querySelector:
return .selectorResolution

Expand Down Expand Up @@ -389,6 +400,7 @@ struct TargetActivationFactPayload: Codable {

struct DataPayload: Codable {
var message: String?
var state: String?
var imageBase64: String?
var text: String?
var found: Bool?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import Darwin
import XCTest

extension RunnerTests {
private static let screenLockStateNotification = "com.apple.springboard.lockstate"
static let screenLockVerificationTimeout: TimeInterval = 5
static let screenLockPollInterval: TimeInterval = 0.05

/// Mirrors WebDriverAgent's audited simulator route: XCTest dispatches the private lock-button
/// primitive and SpringBoard's Darwin notification state independently verifies the transition.
/// The pre-read makes the operation idempotent; a dispatch alone is never reported as success.
func executeScreenLockCommand() -> Response {
#if os(iOS) && targetEnvironment(simulator)
var deadline = Date().addingTimeInterval(Self.screenLockVerificationTimeout)
return executeScreenLockTransition(
readState: currentScreenLockState,
dispatch: dispatchScreenLock,
verifyVisibleSurface: verifyLockScreenSurface,
shouldContinue: { Date() < deadline },
wait: {
RunLoop.current.run(
until: Date().addingTimeInterval(Self.screenLockPollInterval)
)
},
startVerificationWindow: {
deadline = Date().addingTimeInterval(Self.screenLockVerificationTimeout)
}
)
#else
return Response(
ok: false,
error: ErrorPayload(
code: "UNSUPPORTED_OPERATION",
message: "screenLock is supported only on iPhone and iPad Simulators"
)
)
#endif
}

#if os(iOS) && targetEnvironment(simulator)
enum ScreenLockStateRead {
case success(Bool)
case failure(Response)
}

func executeScreenLockTransition(
readState: () -> ScreenLockStateRead,
dispatch: () -> Response?,
verifyVisibleSurface: () -> Bool,
shouldContinue: () -> Bool,
wait: () -> Void,
startVerificationWindow: () -> Void = {}
) -> Response {
switch readState() {
case .success(true):
return screenLockVisibleResponse(
readState: readState,
verifyVisibleSurface: verifyVisibleSurface,
shouldContinue: shouldContinue,
wait: wait
)
case .failure(let response):
return response
case .success(false):
break
}

if let dispatchFailure = dispatch() { return dispatchFailure }
startVerificationWindow()

while shouldContinue() {
switch readState() {
case .success(true):
return screenLockVisibleResponse(
readState: readState,
verifyVisibleSurface: verifyVisibleSurface,
shouldContinue: shouldContinue,
wait: wait
)
case .failure(let response):
return response
case .success(false):
wait()
}
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

// The lock transition may complete during the last wait even when that wait crosses the
// deadline. Read once more before reporting timeout so a completed transition is not lost.
switch readState() {
case .success(true):
return screenLockVisibleResponse(
readState: readState,
verifyVisibleSurface: verifyVisibleSurface,
shouldContinue: shouldContinue,
wait: wait
)
case .failure(let response):
return response
case .success(false):
break
}

return Response(
ok: false,
error: ErrorPayload(
code: "COMMAND_FAILED",
message: "XCTest dispatched the simulator lock button, but SpringBoard did not report a locked screen",
hint: "Verify that the selected Simulator is booted and that its SpringBoard lock-state service is available."
)
)
}

private func screenLockVisibleResponse(
readState: () -> ScreenLockStateRead,
verifyVisibleSurface: () -> Bool,
shouldContinue: () -> Bool,
wait: () -> Void
) -> Response {
while true {
switch readState() {
case .success(false):
return Response(
ok: false,
error: ErrorPayload(
code: "COMMAND_FAILED",
message: "SpringBoard no longer reports a locked screen while verifying the Lock Screen surface"
)
)
case .failure(let response):
return response
case .success(true):
break
}
if verifyVisibleSurface() {
return Response(ok: true, data: DataPayload(message: "Screen locked", state: "locked"))
Comment thread
csark0812 marked this conversation as resolved.
}
guard shouldContinue() else { break }
wait()
}
return Response(
ok: false,
error: ErrorPayload(
code: "COMMAND_FAILED",
message: "SpringBoard reported a locked state, but the Lock Screen surface was not visible"
)
)
}

private func dispatchScreenLock() -> Response? {
let device = XCUIDevice.shared
let selector = NSSelectorFromString("pressLockButton")
guard device.responds(to: selector) else {
return Response(
ok: false,
error: ErrorPayload(
code: "UNSUPPORTED_OPERATION",
message: "The selected XCTest runtime does not expose simulator screen locking"
)
)
}
device.perform(selector)
return nil
}

private func verifyLockScreenSurface() -> Bool {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let dateView = springboard.descendants(matching: .any)
.matching(identifier: "lockscreen-date-view")
Comment thread
csark0812 marked this conversation as resolved.
.firstMatch
let coverSheet = springboard.windows.matching(identifier: "SBCoverSheetWindow").firstMatch
return springboard.exists
&& ((dateView.exists && !dateView.frame.isEmpty)
|| (coverSheet.exists && !coverSheet.frame.isEmpty))
}

func currentScreenLockState() -> ScreenLockStateRead {
var token: Int32 = 0
let registerStatus = notify_register_check(Self.screenLockStateNotification, &token)
guard registerStatus == NOTIFY_STATUS_OK else {
return .failure(screenLockStateReadFailure("register", status: registerStatus))
}
defer { notify_cancel(token) }

var state: UInt64 = 0
let readStatus = notify_get_state(token, &state)
guard readStatus == NOTIFY_STATUS_OK else {
return .failure(screenLockStateReadFailure("read", status: readStatus))
}
return .success(state != 0)
}

private func screenLockStateReadFailure(_ phase: String, status: UInt32) -> Response {
Response(
ok: false,
error: ErrorPayload(
code: "COMMAND_FAILED",
message: "Unable to \(phase) SpringBoard lock state (notify status \(status))"
)
)
}
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ extension RunnerTests {

/// Commands that did not exist at the merge-base, so no classification of its is compared with
/// theirs. `appState` arrived with #2929.
private static let commandsNewerThanTheMergeBase: Set<CommandType> = [.appState]
private static let commandsNewerThanTheMergeBase: Set<CommandType> = [.appState, .screenLock]

/// The commands production never runs through the prepared path's body: `executeOnMain` answers
/// these before `executeOnMainPrepared` runs, and `executeDispatched` answers `snapshot` earlier
Expand Down Expand Up @@ -269,6 +269,10 @@ extension RunnerTests {
.actionButton,
expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true)
),
(
.screenLock,
expectation(interaction: false, retry: false, launch: .noApp, converts: true)
),
(.keyboardDismiss, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
(.keyboardReturn, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
(
Expand Down
Loading