diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h index 90898722a2..c3416e460e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h @@ -1,3 +1,5 @@ +#import + #import "RunnerObjCExceptionCatcher.h" #import "RunnerAXSnapshotBridge.h" #import "RunnerSynthesizedGesture.h" diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index a96560e417..f759c67ae6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -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 { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index 453d559958..bd9add8b91 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -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 diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 73c7c52761..9556ec9a43 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -23,6 +23,7 @@ enum CommandType: String, Codable, CaseIterable { case rotate case appSwitcher case actionButton + case screenLock case keyboardDismiss case keyboardReturn case alert @@ -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. @@ -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 @@ -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? diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift new file mode 100644 index 0000000000..4909d2f7c9 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -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() + } + } + + // 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")) + } + 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") + .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 +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index e018dd5e39..af8640c739 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -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 = [.appState] + private static let commandsNewerThanTheMergeBase: Set = [.appState, .screenLock] /// The commands production never runs through the prepared path's body: `executeOnMain` answers /// these before `executeOnMainPrepared` runs, and `executeDispatched` answers `snapshot` earlier @@ -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)), ( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift new file mode 100644 index 0000000000..42d06e3a21 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -0,0 +1,232 @@ +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) && targetEnvironment(simulator) +extension RunnerTests { + func testScreenLockStartsVerificationWindowAfterDispatchReturns() { + var dispatchStarted = false + var verificationWindowStarted = false + var reads = 0 + let response = executeScreenLockTransition( + readState: { + reads += 1 + return .success(reads > 1) + }, + dispatch: { + dispatchStarted = true + return nil + }, + verifyVisibleSurface: { true }, + shouldContinue: { dispatchStarted && verificationWindowStarted }, + wait: {}, + startVerificationWindow: { verificationWindowStarted = dispatchStarted } + ) + + XCTAssertTrue(response.ok) + XCTAssertTrue(verificationWindowStarted) + XCTAssertEqual(reads, 3) + } + + func testScreenLockReportsSuccessOnlyForLockScreenSpecificSurface() { + defer { unlockSimulatorScreenIfNeeded() } + + let response = executeScreenLockCommand() + + XCTAssertTrue(response.ok, response.error?.message ?? "Expected verified screen lock") + XCTAssertEqual(response.data?.state, "locked") + let dateView = springboard.descendants(matching: .any) + .matching(identifier: "lockscreen-date-view") + .firstMatch + let coverSheet = springboard.windows.matching(identifier: "SBCoverSheetWindow").firstMatch + XCTAssertTrue( + (dateView.exists && !dateView.frame.isEmpty) + || (coverSheet.exists && !coverSheet.frame.isEmpty), + springboard.debugDescription + ) + } + + func testScreenLockIsIdempotentWhenAlreadyLocked() { + var dispatches = 0 + let response = executeScreenLockTransition( + readState: { .success(true) }, + dispatch: { + dispatches += 1 + return nil + }, + verifyVisibleSurface: { true }, + shouldContinue: { false }, + wait: {} + ) + XCTAssertTrue(response.ok) + XCTAssertEqual(response.data?.state, "locked") + XCTAssertEqual(dispatches, 0) + } + + func testScreenLockWaitsForTheVerifiedTransition() { + var reads = [false, false, true, true] + var dispatches = 0 + var waits = 0 + let response = executeScreenLockTransition( + readState: { .success(reads.removeFirst()) }, + dispatch: { + dispatches += 1 + return nil + }, + verifyVisibleSurface: { true }, + shouldContinue: { !reads.isEmpty }, + wait: { waits += 1 } + ) + XCTAssertTrue(response.ok) + XCTAssertEqual(dispatches, 1) + XCTAssertEqual(waits, 1) + } + + func testScreenLockPropagatesUnavailableHidWithoutPolling() { + var polled = false + let response = executeScreenLockTransition( + readState: { .success(false) }, + dispatch: { + Response( + ok: false, + error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: "HID unavailable") + ) + }, + verifyVisibleSurface: { true }, + shouldContinue: { + polled = true + return true + }, + wait: {} + ) + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "UNSUPPORTED_OPERATION") + XCTAssertFalse(polled) + } + + func testScreenLockRejectsAnUnverifiedVisibleSurface() { + let response = executeScreenLockTransition( + readState: { .success(true) }, + dispatch: { nil }, + verifyVisibleSurface: { false }, + shouldContinue: { false }, + wait: {} + ) + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "COMMAND_FAILED") + XCTAssertTrue(response.error?.message.contains("not visible") == true) + } + + func testScreenLockWaitsForVisibleSurfaceAfterLockStateChanges() { + var visible = false + var waits = 0 + let response = executeScreenLockTransition( + readState: { .success(true) }, + dispatch: { nil }, + verifyVisibleSurface: { + visible = waits > 0 + return visible + }, + shouldContinue: { waits < 1 }, + wait: { waits += 1 } + ) + XCTAssertTrue(response.ok) + XCTAssertEqual(waits, 1) + } + + func testScreenLockDoesNotReportSuccessIfTheDeviceUnlocksDuringSurfaceVerification() { + var states = [true, false] + var waits = 0 + let response = executeScreenLockTransition( + readState: { .success(states.removeFirst()) }, + dispatch: { nil }, + verifyVisibleSurface: { false }, + shouldContinue: { true }, + wait: { waits += 1 } + ) + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "COMMAND_FAILED") + XCTAssertTrue(response.error?.message.contains("no longer reports") == true) + XCTAssertEqual(waits, 0) + } + + func testScreenLockTimesOutWhileSimulatorIsStillBooting() { + var dispatches = 0 + let response = executeScreenLockTransition( + readState: { .success(false) }, + dispatch: { + dispatches += 1 + return nil + }, + verifyVisibleSurface: { true }, + shouldContinue: { false }, + wait: {} + ) + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "COMMAND_FAILED") + XCTAssertEqual(dispatches, 1) + } + + func testScreenLockPerformsFinalReadAfterDeadline() { + var reads = 0 + let response = executeScreenLockTransition( + readState: { + reads += 1 + return .success(reads >= 3) + }, + dispatch: { nil }, + verifyVisibleSurface: { true }, + shouldContinue: { reads < 2 }, + wait: {} + ) + XCTAssertTrue(response.ok) + XCTAssertEqual(reads, 4) + } + + func testScreenLockPropagatesLockStateReadFailure() { + let failure = Response( + ok: false, + error: ErrorPayload(code: "COMMAND_FAILED", message: "notify failure") + ) + let response = executeScreenLockTransition( + readState: { .failure(failure) }, + dispatch: { nil }, + verifyVisibleSurface: { true }, + shouldContinue: { true }, + wait: {} + ) + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.message, "notify failure") + } + + private func unlockSimulatorScreenIfNeeded() { + guard case .success(true) = currentScreenLockState() else { return } + + // XCTest's simulator app launch path wakes the Lock Screen without requiring test-only + // passcode or biometric setup. Use the built-in Settings app so cleanup is independent of the + // optional Agent Device Tester fixture. + XCUIApplication(bundleIdentifier: "com.apple.Preferences").launch() + if case .success(false) = currentScreenLockState() { return } + + let start = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)) + let end = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)) + start.press(forDuration: 0.1, thenDragTo: end) + + let deadline = Date().addingTimeInterval(Self.screenLockVerificationTimeout) + while Date() < deadline { + if case .success(false) = currentScreenLockState() { return } + if springboard.buttons["Emergency"].exists + || springboard.staticTexts["Enter Passcode"].exists + { + XCTFail( + "The screen-lock integration test left a passcode-protected Lock Screen; " + + "unlock the Simulator manually before rerunning" + ) + return + } + RunLoop.current.run( + until: Date().addingTimeInterval(Self.screenLockPollInterval) + ) + } + XCTFail("The screen-lock integration test could not restore the Simulator to unlocked state") + } +} +#endif diff --git a/contracts/fixtures/runner-requests.json b/contracts/fixtures/runner-requests.json index 730c8cb359..571bd6afd2 100644 --- a/contracts/fixtures/runner-requests.json +++ b/contracts/fixtures/runner-requests.json @@ -41,6 +41,7 @@ {"name": "ios-simulator.interactor-keyboard-dismiss.dismiss", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "keyboardDismiss", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-keyboard-enter.return", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "keyboardReturn", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-read-text.point", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "readText", "x": 10, "y": 20, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-screen-lock.lock", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "screenLock", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-set-orientation.rotate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "rotate", "orientation": "landscape-left", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-snapshot.every-option", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "preferredBackend": "tree", "customActions": true, "depth": 3, "scope": "Go", "raw": true, "commandId": ""}}, {"name": "ios-simulator.recording-clock-anchor.snapshot", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "depth": 1}}, diff --git a/packages/command-registry/src/__tests__/command-result.test.ts b/packages/command-registry/src/__tests__/command-result.test.ts index 4cc3f6a7db..25a128f8b3 100644 --- a/packages/command-registry/src/__tests__/command-result.test.ts +++ b/packages/command-registry/src/__tests__/command-result.test.ts @@ -14,6 +14,7 @@ import type { BackCommandResult, HomeCommandResult, OrientationCommandResult, + ScreenLockCommandResult, TvRemoteCommandResult, } from '@agent-device/contracts/navigation'; import type { ClipboardCommandResult } from '@agent-device/contracts/clipboard'; @@ -56,6 +57,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ const back: Equal, BackCommandResult> = true; const orientation: Equal, OrientationCommandResult> = true; const appSwitcher: Equal, AppSwitcherCommandResult> = true; + const screenLock: Equal, ScreenLockCommandResult> = true; const clipboard: Equal, ClipboardCommandResult> = true; const appstate: Equal, AppStateCommandResult> = true; const keyboard: Equal, KeyboardCommandResult> = true; @@ -86,6 +88,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ back, orientation, appSwitcher, + screenLock, clipboard, appstate, keyboard, @@ -127,6 +130,7 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'orientation' | 'app-switcher' | 'action-button' + | 'screen-lock' | 'fold' | 'clipboard' | 'appstate' diff --git a/packages/command-registry/src/command-result.ts b/packages/command-registry/src/command-result.ts index 11f9d271e9..5c580b7c6d 100644 --- a/packages/command-registry/src/command-result.ts +++ b/packages/command-registry/src/command-result.ts @@ -16,6 +16,7 @@ import type { FoldCommandResult, HomeCommandResult, OrientationCommandResult, + ScreenLockCommandResult, TvRemoteCommandResult, } from '@agent-device/contracts/navigation'; import type { @@ -80,6 +81,7 @@ export interface CommandResultMap { push: PushCommandResult; record: RecordingCommandResult; replay: ReplayCommandResult; + 'screen-lock': ScreenLockCommandResult; scroll: ScrollCommandResult; shutdown: ShutdownCommandResult; test: ReplaySuiteResult; diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 43ebe1a20c..12382e6154 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -41,6 +41,7 @@ import { inventoryUse } from '@agent-device/contracts/platform-module'; import { alertRuntimePlanUses, actionButtonRuntimeUse, + screenLockRuntimeUse, foldRuntimeUse, appEventRuntimeUse, appStateRuntimeUses, @@ -1575,6 +1576,16 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...GENERIC_MUTATING_COMMAND_TRAITS, platformExecution: { kind: 'device-runtime', uses: [actionButtonRuntimeUse] }, }, + { + name: 'screen-lock', + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), + catalog: { group: 'public', key: 'screenLock' }, + frameworkTier: 'extended', + // A verified Lock Screen transition is a system mutation. It deliberately has no settle + // observation: reactivating the session app would destroy the surface this command creates. + ...GENERIC_MUTATING_COMMAND_TRAITS, + platformExecution: { kind: 'device-runtime', uses: [screenLockRuntimeUse] }, + }, { name: 'install-from-source', deviceClaimPolicy: 'none', diff --git a/packages/contracts/src/client-system.ts b/packages/contracts/src/client-system.ts index 1bf5dd6ca5..054bdd2f63 100644 --- a/packages/contracts/src/client-system.ts +++ b/packages/contracts/src/client-system.ts @@ -96,6 +96,8 @@ export type AppSwitcherCommandOptions = DeviceCommandBaseOptions; export type ActionButtonCommandOptions = DeviceCommandBaseOptions; +export type ScreenLockCommandOptions = DeviceCommandBaseOptions; + export type TvRemoteCommandOptions = DeviceCommandBaseOptions & { button: TvRemoteButton; durationMs?: number; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index 8cd5d9dad2..2f99c7e0f8 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -119,6 +119,7 @@ export type { OrientationCommandOptions, PrepareCommandOptions, ReactNativeCommandOptions, + ScreenLockCommandOptions, TvRemoteCommandOptions, ViewportCommandOptions, WaitCommandOptions, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 80be944e06..dcd3628fac 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -98,6 +98,11 @@ export const INTERACTOR_OPERATIONS = [ label: SYSTEM_BUTTON_LABELS.actionButton, bind: (signal, resolve) => bindSystemButton('actionButton', signal, resolve), }, + { + operation: 'screenLock', + label: SYSTEM_BUTTON_LABELS.screenLock, + bind: (signal, resolve) => bindSystemButton('screenLock', signal, resolve), + }, { operation: 'triggerAppEvent', label: 'trigger-app-event', bind: bindAppEvent }, { operation: 'setSetting', label: 'settings', bind: bindSetSetting }, { operation: 'readSetting', label: 'settings read', bind: bindReadSetting }, diff --git a/packages/contracts/src/interactor-operation-conformance.test.ts b/packages/contracts/src/interactor-operation-conformance.test.ts index bca8508288..6c6a134c1c 100644 --- a/packages/contracts/src/interactor-operation-conformance.test.ts +++ b/packages/contracts/src/interactor-operation-conformance.test.ts @@ -35,6 +35,7 @@ const EXPECTATIONS: { writeClipboard: { method: 'writeClipboard', input: { text: '' } }, appSwitcher: { method: 'appSwitcher', input: {} }, actionButton: { method: 'actionButton', input: {} }, + screenLock: { method: 'screenLock', input: {} }, triggerAppEvent: { method: 'open', input: { eventUrl: 'myapp://x' } }, setSetting: { method: 'setSetting', diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index dd5cb82363..f132ba8b7a 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -398,6 +398,12 @@ export type Interactor = { * absent member as a successful no-op. */ actionButton?(): Promise; + /** + * Optional: transitions an iPhone/iPad Simulator to its Lock Screen and returns only after the + * runner observes SpringBoard's lock state. This is screen state, never a process or device + * ownership lock. + */ + screenLock?(): Promise; /** Optional: only Android implements a live status read (see {@link KeyboardStatusResult}). */ keyboardStatus?(): Promise; /** Optional: platforms with no keyboard-dismiss concept leave it undefined. */ diff --git a/packages/contracts/src/navigation.ts b/packages/contracts/src/navigation.ts index fca6600b99..47aba197b6 100644 --- a/packages/contracts/src/navigation.ts +++ b/packages/contracts/src/navigation.ts @@ -82,6 +82,13 @@ export type ActionButtonCommandResult = { message: string; }; +/** `screen-lock` succeeds only after the selected Simulator reports a locked state and Lock Screen date surface. */ +export type ScreenLockCommandResult = { + action: 'screen-lock'; + state: 'locked'; + message: string; +}; + /** `tv-remote` — `{ action: 'tv-remote', button, durationMs?, message }`. */ export type TvRemoteCommandResult = { action: 'tv-remote'; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3d6981294d..b609aec864 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -118,6 +118,7 @@ export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); export const appSwitcherRuntimeUse = defineUse({ required: ['appSwitcher'] }); export const actionButtonRuntimeUse = defineUse({ required: ['actionButton'] }); +export const screenLockRuntimeUse = defineUse({ required: ['screenLock'] }); export const appEventRuntimeUse = defineUse({ required: ['triggerAppEvent'] }); export const settingsRuntimeUse = defineUse({ required: ['setSetting'] }); export const settingReadUse = defineUse({ required: ['readSetting'] }); diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts index 25bf9a1c7b..0abe97683a 100644 --- a/packages/contracts/src/runtime-operation-names.ts +++ b/packages/contracts/src/runtime-operation-names.ts @@ -8,6 +8,7 @@ export const RUNTIME_OPERATION_NAMES = [ 'acceptAlert', 'actionButton', + 'screenLock', 'appLogCleanup', 'appLogDoctor', 'appLogInspect', diff --git a/packages/contracts/src/system-button-runtime.test.ts b/packages/contracts/src/system-button-runtime.test.ts index 13468254d1..49196dec05 100644 --- a/packages/contracts/src/system-button-runtime.test.ts +++ b/packages/contracts/src/system-button-runtime.test.ts @@ -22,11 +22,13 @@ test('an omitted button reports the family denial, a named one its own cell', () home: available, appSwitcher: unsupported, actionButton: unsupported, + screenLock: unsupported, }); expect(systemButtonRuntimeOperationFacts({ unsupported })).toEqual({ home: unsupported, appSwitcher: unsupported, actionButton: unsupported, + screenLock: unsupported, }); }); diff --git a/packages/contracts/src/system-button-runtime.ts b/packages/contracts/src/system-button-runtime.ts index 9b17bd4dfd..0c5a927382 100644 --- a/packages/contracts/src/system-button-runtime.ts +++ b/packages/contracts/src/system-button-runtime.ts @@ -5,11 +5,12 @@ import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; /** * The system buttons: one press each, no arguments, nothing returned. `home` and `appSwitcher` - * reach a springboard or recents surface; `actionButton` presses iPhone/iPad hardware. What + * reach a springboard or recents surface; `actionButton` presses iPhone/iPad hardware; `screenLock` + * transitions a supported simulator to its verified Lock Screen. What * varies between them is which owners carry the control, and that is the fact table's job, not * a per-button module's: a button joins this list and its owners state a cell. */ -export const SYSTEM_BUTTONS = ['home', 'appSwitcher', 'actionButton'] as const; +export const SYSTEM_BUTTONS = ['home', 'appSwitcher', 'actionButton', 'screenLock'] as const; export type SystemButton = (typeof SYSTEM_BUTTONS)[number]; @@ -18,6 +19,7 @@ export const SYSTEM_BUTTON_LABELS = { home: 'home', appSwitcher: 'app-switcher', actionButton: 'action-button', + screenLock: 'screen-lock', } as const satisfies Record; /** Neutral intent for one press: no arguments, so only runner metadata travels. */ diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 011afb24f9..a63d1b337c 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -215,6 +215,25 @@ test('Android refuses the action-button fact on every kind', async () => { } }); +test('Android refuses screen-lock on every kind', async () => { + for (const runtimeDevice of [ + ANDROID_EMULATOR, + { ...ANDROID_EMULATOR, kind: 'device' as const }, + UNKNOWN_KIND_DEVICE, + ]) { + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + runtimeDevice, + ); + expect(binding.facts.operations.screenLock).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Android has no key event for this system button.', + }); + expect(binding.operations.screenLock).toBeUndefined(); + } +}); + test('Android refuses the fold fact on every kind', async () => { for (const runtimeDevice of [ ANDROID_EMULATOR, diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index eed7d1d34e..7dd7ba43dd 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -75,6 +75,7 @@ const RUNNER_TRANSPORT_METHODS: Record< setOrientation: { invoke: (i) => i.setOrientation('portrait'), runnerCommand: 'rotate' }, appSwitcher: { invoke: (i) => i.appSwitcher!(), runnerCommand: 'appSwitcher' }, actionButton: { invoke: (i) => i.actionButton!(), runnerCommand: 'actionButton' }, + screenLock: { invoke: (i) => i.screenLock!(), runnerCommand: 'screenLock' }, tvRemote: { invoke: (i) => i.tvRemote!('select'), runnerCommand: 'remotePress' }, keyboardDismiss: { invoke: (i) => i.keyboardDismiss!(), runnerCommand: 'keyboardDismiss' }, keyboardEnter: { invoke: (i) => i.keyboardEnter!(), runnerCommand: 'keyboardReturn' }, diff --git a/packages/platform-apple/src/__tests__/runner-requests.test.ts b/packages/platform-apple/src/__tests__/runner-requests.test.ts index e053178cc9..5f8aa0cfe8 100644 --- a/packages/platform-apple/src/__tests__/runner-requests.test.ts +++ b/packages/platform-apple/src/__tests__/runner-requests.test.ts @@ -151,6 +151,7 @@ const INTERACTOR_SITES: Record = { 'ios-simulator.interactor-app-state.read': [IOS_SIMULATOR, (i) => i.appState!()], 'ios-simulator.interactor-app-switcher.open': [IOS_SIMULATOR, (i) => i.appSwitcher!()], 'ios-simulator.interactor-action-button.press': [IOS_SIMULATOR, (i) => i.actionButton!()], + 'ios-simulator.interactor-screen-lock.lock': [IOS_SIMULATOR, (i) => i.screenLock!()], 'tvos.interactor-tv-remote.hold': [TVOS_SIMULATOR, (i) => i.tvRemote!('select', 500)], 'ios-simulator.interactor-keyboard-dismiss.dismiss': [IOS_SIMULATOR, (i) => i.keyboardDismiss!()], 'ios-simulator.interactor-keyboard-enter.return': [IOS_SIMULATOR, (i) => i.keyboardEnter!()], diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index 0d960fea39..3d8f09c6d2 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -154,6 +154,13 @@ export function createAppleInteractor( runnerOpts, ); }, + screenLock: async () => { + await runAppleRunnerCommand( + device, + { command: 'screenLock', appBundleId: runnerContext.appBundleId }, + runnerOpts, + ); + }, tvRemote: async (button, durationMs) => { await runAppleRunnerCommand( device, diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts index eb0d8af2e9..dc1f084803 100644 --- a/packages/platform-apple/src/navigation/runtime.ts +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -8,6 +8,7 @@ import { systemButtonRuntimeOperationFacts } from '@agent-device/contracts/syste import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import { hasAppleActionButton, + isHandheldAppleSimulator, isTvOsDevice, resolveDeviceAppleOs, type DeviceInfo, @@ -147,6 +148,20 @@ const actionButtonOsUnavailable = Object.freeze({ reason: 'unsupported-platform-leaf', hint: 'The Action Button is iPhone and iPad hardware; tvOS, macOS, watchOS and visionOS have no such control.', } as const); +const screenLockKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'screen-lock is supported only on iPhone and iPad Simulators.', +} as const); +const screenLockOsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'screen-lock is an iPhone and iPad Simulator operation; physical devices, tvOS, macOS, watchOS and visionOS are unsupported.', +} as const); +function appleScreenLockFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator') return screenLockKindUnavailable; + return isHandheldAppleSimulator(device) ? available : screenLockOsUnavailable; +} /** * The leaf reading is {@link hasAppleActionButton}, the same rule a provider owner reads; what this * owner adds is its kind gate. The leaf is the whole claim: which model inside it carries the button @@ -171,6 +186,7 @@ export function appleNavigationFacts(device: DeviceInfo) { home: appleSpringboardFact(device, homeKindUnavailable), appSwitcher: appleSpringboardFact(device, appSwitcherKindUnavailable), actionButton: appleActionButtonFact(device), + screenLock: appleScreenLockFact(device), }), ...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }), diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index e37e380376..f88d69eabb 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -81,6 +81,7 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< setOrientation: 'runner', appSwitcher: 'runner', actionButton: 'runner', + screenLock: 'runner', tvRemote: 'runner', keyboardDismiss: 'runner', keyboardEnter: 'runner', diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts index ead94b43cd..ed13bf24d3 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts @@ -57,6 +57,7 @@ test('runner command trait table pins lifecycle-sensitive command groups', () => 'recordStop', 'remotePress', 'rotate', + 'screenLock', 'shutdown', 'type', ], diff --git a/packages/platform-apple/src/runner/runner-command-traits.ts b/packages/platform-apple/src/runner/runner-command-traits.ts index 79ec993b28..001285c2db 100644 --- a/packages/platform-apple/src/runner/runner-command-traits.ts +++ b/packages/platform-apple/src/runner/runner-command-traits.ts @@ -74,6 +74,7 @@ export const RUNNER_COMMAND_TRAITS = { gestureViewport: READ_ONLY_TRAITS, appSwitcher: DEFAULT_TRAITS, actionButton: DEFAULT_TRAITS, + screenLock: DEFAULT_TRAITS, keyboardDismiss: DEFAULT_TRAITS, keyboardReturn: DEFAULT_TRAITS, alert: readAlertActionTraits, diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 1a87558441..4745292415 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -68,6 +68,7 @@ export type RunnerCommand = { | 'gestureViewport' | 'appSwitcher' | 'actionButton' + | 'screenLock' | 'keyboardDismiss' | 'keyboardReturn' | 'alert' diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index fdee94259d..8039e89b5d 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -222,7 +222,7 @@ function expectAppleSnapshotAvailability( } test.each(Object.entries(leaves))( - 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/screen-lock/orientation/tv-remote/keyboard facts for the %s leaf', async (_name, device) => { const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ device, @@ -374,6 +374,18 @@ function expectNavigationAndKeyboardFacts( expectOperationAvailability(binding, 'home', springboard); expectOperationAvailability(binding, 'appSwitcher', springboard); + // Screen locking is a simulator-only host transition on iPhone and iPad. Physical devices and + // every other Apple platform leaf must refuse it before runner dispatch. + const screenLock = + device.kind === 'simulator' && (device.appleOs === 'ios' || device.appleOs === 'ipados'); + expectOperationAvailability(binding, 'screenLock', screenLock); + if (!screenLock) { + expect(binding.facts.operations.screenLock).toHaveProperty( + 'reason', + device.kind === 'simulator' ? 'unsupported-platform-leaf' : 'unsupported-device-kind', + ); + } + // orientation and keyboard dismiss/enter share mobile-input eligibility: unavailable on tvOS // (focus-only XCUIRemote navigation), macOS (an AppKit desktop host), and watchOS. const mobileInputEligible = diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 9da74e6353..a55a52e5f2 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -220,6 +220,8 @@ function expectLinuxNavigationAndKeyboardFacts( 'appSwitcher', // The Action Button is iPhone/iPad hardware; the Linux desktop has no equivalent control. 'actionButton', + // Lock Screen transitions are an iPhone/iPad Simulator host operation. + 'screenLock', // Nor does it have a foldable hinge to pose. 'setFoldPose', // R57: the retired `trigger-app-event` descriptor declared `linux: {}` too. diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 5ba868a300..15c7dca363 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -214,6 +214,7 @@ test('clipboard, the app switcher, app events, settings and alerts carry no web 'appSwitcher', // The Action Button is iPhone/iPad hardware with no web analogue at all. 'actionButton', + 'screenLock', // A foldable hinge is posed through the host's iOS simulator HID helper; the web target has // none. 'setFoldPose', diff --git a/src/__tests__/test-utils/property-arbitraries.ts b/src/__tests__/test-utils/property-arbitraries.ts index 4767f11f81..731e559180 100644 --- a/src/__tests__/test-utils/property-arbitraries.ts +++ b/src/__tests__/test-utils/property-arbitraries.ts @@ -314,6 +314,7 @@ const REPLAY_SCRIPT_LINE_PLANS = { reinstall: GENERIC_REPLAY_LINE, replay: GENERIC_REPLAY_LINE, scroll: GENERIC_REPLAY_LINE, + 'screen-lock': GENERIC_REPLAY_LINE, settings: GENERIC_REPLAY_LINE, shutdown: GENERIC_REPLAY_LINE, test: GENERIC_REPLAY_LINE, diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index e616dabbc2..a71e176628 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -149,6 +149,8 @@ export function createAgentDeviceClient( await executeCommand>('app-switcher', options), actionButton: async (options = {}) => await executeCommand>('action-button', options), + screenLock: async (options = {}) => + await executeCommand>('screen-lock', options), keyboard: async (options = {}) => await executeCommand>('keyboard', options), clipboard: async (options) => diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 25b493df43..5570861481 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -34,17 +34,11 @@ export type { // them from the declaring module instead. Fallow therefore sees no consumer, which is exactly // right and exactly not actionable: deleting them would remove names from the package's public // types. Suppressed per name rather than baselined so the reason travels with the code. -// fallow-ignore-next-line unused-type export type { TargetShutdownResult } from '@agent-device/contracts/device'; -// fallow-ignore-next-line unused-type export type { MetroBridgeScope } from '@agent-device/contracts/remote'; -// fallow-ignore-next-line unused-type export type { AppsFilter } from '@agent-device/contracts/device'; -// fallow-ignore-next-line unused-type export type { AlertAction } from '@agent-device/contracts/alert-contract'; -// fallow-ignore-next-line unused-type export type { AppleOS } from '@agent-device/kernel/device'; -// fallow-ignore-next-line unused-type export type { JsonObject } from '@agent-device/contracts/client'; export type { BatchRunResult } from '@agent-device/command-registry/batch'; @@ -115,6 +109,7 @@ import type { PrepareCommandOptions, PressOptions, ReactNativeCommandOptions, + ScreenLockCommandOptions, RecordOptions, ReplayRunOptions, ReplayTestOptions, @@ -169,6 +164,7 @@ export type AgentDeviceCommandClient = { fold: (options: FoldCommandOptions) => Promise>; appSwitcher: (options?: AppSwitcherCommandOptions) => Promise>; actionButton: (options?: ActionButtonCommandOptions) => Promise>; + screenLock: (options?: ScreenLockCommandOptions) => Promise>; tvRemote: (options: TvRemoteCommandOptions) => Promise>; wait: (options: WaitCommandOptions) => Promise>; alert: (options?: AlertCommandOptions) => Promise; diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index deb81e3c11..1ea9e7e185 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -7,6 +7,7 @@ import type { FoldCommandOptions, HomeCommandOptions, OrientationCommandOptions, + ScreenLockCommandOptions, TvRemoteCommandOptions, } from '../../client/client-types.ts'; import type { CommandResult } from '@agent-device/command-registry/command-result'; @@ -62,12 +63,21 @@ describe('system command interface', () => { expectTypeOf().toEqualTypeOf< (options?: ActionButtonCommandOptions) => Promise> >(); + expectTypeOf().toEqualTypeOf< + (options?: ScreenLockCommandOptions) => Promise> + >(); expectTypeOf().toEqualTypeOf< (options: TvRemoteCommandOptions) => Promise> >(); }); - const parameterless = ['appstate', 'home', 'app-switcher', 'action-button'] as const; + const parameterless = [ + 'appstate', + 'home', + 'app-switcher', + 'action-button', + 'screen-lock', + ] as const; test('parameterless readers project common selection flags through', () => { for (const command of parameterless) { diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 25fe92323f..579ccc996d 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -56,6 +56,7 @@ const ORIENTATION_COMMAND_NAME = 'orientation'; const FOLD_COMMAND_NAME = 'fold'; const APP_SWITCHER_COMMAND_NAME = 'app-switcher'; const ACTION_BUTTON_COMMAND_NAME = 'action-button'; +const SCREEN_LOCK_COMMAND_NAME = 'screen-lock'; const KEYBOARD_COMMAND_NAME = 'keyboard'; const CLIPBOARD_COMMAND_NAME = 'clipboard'; const TV_REMOTE_COMMAND_NAME = 'tv-remote'; @@ -81,6 +82,8 @@ const clipboardCommandDescription = 'Read the current device clipboard text, or replace its contents with the given text. Android runs both through the clipboard service shell command, and a build that implements none (Android 16 does not) refuses with UNSUPPORTED_OPERATION rather than reporting an empty clipboard.'; const actionButtonCommandDescription = 'Press the iPhone or iPad Action Button once. The press is dispatched without activating the session app and nothing is re-observed afterwards, so the app keeps the state the press found. What the system does with the press is not observed by this command: Simulators run no Shortcuts or App Intents, so delivery to an assigned Shortcut is verifiable only on a physical iPhone.'; +const screenLockCommandDescription = + 'Transition an iPhone or iPad Simulator to its Lock Screen. The command is idempotent and returns only after SpringBoard reports the screen locked and exposes its Lock Screen date surface. This controls simulated screen state; it is unrelated to process mutexes, device claims, or runner leases.'; const tvRemoteCommandDescription = 'Press or long-press a TV remote or D-pad button on Android TV, tvOS, or Vega OS. Choose the button and optional hold duration through the input fields. The aliases ok, center, and enter all map to select.'; @@ -381,6 +384,18 @@ const actionButtonCommandFacet = defineParameterlessCommandFacet({ cliOutputFormatter: systemCliOutputFormatters['action-button'], }); +const screenLockCommandFacet = defineParameterlessCommandFacet({ + name: SCREEN_LOCK_COMMAND_NAME, + description: screenLockCommandDescription, + text: { + summary: 'Lock the iPhone or iPad Simulator screen', + cliDetail: + 'Simulator-only. Success requires SpringBoard lock state and the Lock Screen date surface.', + }, + run: (client, input) => client.command.screenLock(input), + cliOutputFormatter: systemCliOutputFormatters['screen-lock'], +}); + const tvRemoteCommandFacet = defineCommandFacet({ name: TV_REMOTE_COMMAND_NAME, text: { @@ -405,6 +420,7 @@ export const systemCommandFamily = defineCommandFamilyFromFacets({ foldCommandFacet, appSwitcherCommandFacet, actionButtonCommandFacet, + screenLockCommandFacet, keyboardCommandFacet, clipboardCommandFacet, tvRemoteCommandFacet, diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts index eedaef0403..bd15f52cf6 100644 --- a/src/commands/system/output.ts +++ b/src/commands/system/output.ts @@ -49,6 +49,7 @@ export const systemCliOutputFormatters = withSettleCapableNotes({ fold: messageOutput, 'app-switcher': messageOutput, 'action-button': messageOutput, + 'screen-lock': messageOutput, keyboard: resultOutput(keyboardCliOutput), clipboard: resultOutput(clipboardCliOutput), 'tv-remote': messageOutput, diff --git a/src/daemon/__tests__/system-button-runtime.test.ts b/src/daemon/__tests__/system-button-runtime.test.ts index f6e1b49a12..d56d20ee2d 100644 --- a/src/daemon/__tests__/system-button-runtime.test.ts +++ b/src/daemon/__tests__/system-button-runtime.test.ts @@ -12,6 +12,7 @@ import { actionButtonRuntimeUse, appSwitcherRuntimeUse, homeRuntimeUse, + screenLockRuntimeUse, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; import type { SystemButton } from '@agent-device/contracts/system-button-runtime'; @@ -63,7 +64,13 @@ const unavailable = Object.freeze({ */ const EXPECTED: Record< SystemButtonCommand, - { button: SystemButton; use: unknown; message: string; refusedOn: DeviceInfo } + { + button: SystemButton; + use: unknown; + message: string; + refusedOn: DeviceInfo; + state?: 'locked'; + } > = { home: { button: 'home', use: homeRuntimeUse, message: 'Home', refusedOn: macOsDevice }, 'app-switcher': { @@ -78,6 +85,13 @@ const EXPECTED: Record< message: 'Pressed Action Button', refusedOn: iosSimulator, }, + 'screen-lock': { + button: 'screenLock', + use: screenLockRuntimeUse, + message: 'Screen locked', + refusedOn: macOsDevice, + state: 'locked', + }, }; test('the registry names exactly the commands the press route serves', () => { @@ -149,6 +163,7 @@ test.each(Object.keys(EXPECTED) as SystemButtonCommand[])( expect(await resolved.execute(executionParams(command))).toEqual({ action: command, message: expected.message, + ...(expected.state === undefined ? {} : { state: expected.state }), }); expect(harness.press).toHaveBeenCalledTimes(1); }, diff --git a/src/daemon/system-button-runtime.ts b/src/daemon/system-button-runtime.ts index 17fbf4e78e..0928c75315 100644 --- a/src/daemon/system-button-runtime.ts +++ b/src/daemon/system-button-runtime.ts @@ -2,6 +2,7 @@ import { actionButtonRuntimeUse, appSwitcherRuntimeUse, homeRuntimeUse, + screenLockRuntimeUse, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; import type { BoundDeviceRuntime, RuntimeUse } from '@agent-device/contracts/platform-runtime'; @@ -26,20 +27,22 @@ type SystemButtonUse = RuntimeUse< type SystemButtonCommandRow = Readonly<{ /** The registry's declared use for the command: exactly one system-button cell. */ use: SystemButtonUse; - /** The success text the press reports; the response carries nothing else by design. */ + /** The success text the press reports. */ message: string; + state?: 'locked'; }>; /** * The generic-route commands that are one system-button press each (ADR 0019). A press has no * arguments, no settle and no observation payload: it is delivered to whatever the system routes - * it to, and the response is the button's success text. One row per command is what keeps a new + * it to. Screen lock additionally reports its verified terminal state. One row per command keeps a new * button from growing a module, a dispatcher arm and a conformance entry of its own. */ const SYSTEM_BUTTON_COMMANDS = { home: { use: homeRuntimeUse, message: 'Home' }, 'app-switcher': { use: appSwitcherRuntimeUse, message: 'Opened app switcher' }, 'action-button': { use: actionButtonRuntimeUse, message: 'Pressed Action Button' }, + 'screen-lock': { use: screenLockRuntimeUse, message: 'Screen locked', state: 'locked' }, } as const satisfies Record; export type SystemButtonCommand = keyof typeof SYSTEM_BUTTON_COMMANDS; @@ -77,7 +80,11 @@ export async function resolveBoundSystemButtonRuntime( async (runtime: BoundDeviceRuntime, context) => { const [button] = row.use.required; await runtime.operations[button](systemButtonInput(context)); - return { action: command, ...successText(row.message) }; + return { + action: command, + ...(row.state ? { state: row.state } : {}), + ...successText(row.message), + }; }, ); } diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 742a5835db..a92483b7e9 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -531,6 +531,14 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { 'action', 'message', ]), + 'screen-lock': objectSchema( + { + action: constSchema('screen-lock'), + state: constSchema('locked'), + message: stringSchema(), + }, + ['action', 'state', 'message'], + ), 'tv-remote': objectSchema( { action: constSchema('tv-remote'), diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index b3adbb26c4..38df8b96a2 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -18,6 +18,7 @@ import { ANDROID_APPLICATION_LIFECYCLE_CONTRACT_EVIDENCE, ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE, ANDROID_HOVER_RUNTIME_CONTRACT_EVIDENCE, + ANDROID_SCREEN_LOCK_RUNTIME_CONTRACT_EVIDENCE, ANDROID_TV_REMOTE_RUNTIME_CONTRACT_EVIDENCE, ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE, APPLE_ACTION_BUTTON_FACT_EVIDENCE, @@ -1104,6 +1105,37 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'Linux provider scenario dispatches Super+D through the semantic input provider', ), }, + [C.screenLock]: { + androidEmulator: androidEmulator.contract( + ANDROID_SCREEN_LOCK_RUNTIME_CONTRACT_EVIDENCE, + 'the Android runtime refuses screen-lock before dispatch on every target kind', + ), + iosSimulator: iosSimulator.contract( + APPLE_NAVIGATION_FACTS_EVIDENCE.path, + APPLE_NAVIGATION_FACTS_EVIDENCE.test, + 'the exact-owner runtime fact admits screen-lock only on the iOS/iPadOS simulator leaf', + ), + macos: macos.contract( + APPLE_NAVIGATION_FACTS_EVIDENCE.path, + APPLE_NAVIGATION_FACTS_EVIDENCE.test, + 'the exact-owner runtime fact refuses screen-lock on macOS', + ), + tvos: tvos.contract( + APPLE_NAVIGATION_FACTS_EVIDENCE.path, + APPLE_NAVIGATION_FACTS_EVIDENCE.test, + 'the exact-owner runtime fact refuses screen-lock on tvOS', + ), + web: web.contract( + WEB_SYSTEM_SURFACE_DENIAL_EVIDENCE.path, + WEB_SYSTEM_SURFACE_DENIAL_EVIDENCE.test, + 'the exact-owner runtime fact refuses screen-lock on web targets', + ), + linux: linux.contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact refuses screen-lock on Linux targets', + ), + }, [C.tvRemote]: { androidEmulator: androidEmulator.contract( ANDROID_TV_REMOTE_RUNTIME_CONTRACT_EVIDENCE, diff --git a/test/integration/command-coverage/evidence.ts b/test/integration/command-coverage/evidence.ts index 42aed9865b..4fd85c1667 100644 --- a/test/integration/command-coverage/evidence.ts +++ b/test/integration/command-coverage/evidence.ts @@ -44,6 +44,12 @@ export const ANDROID_ACTION_BUTTON_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvi [C.actionButton], 'Android refuses the action-button fact on every kind', ); +export const ANDROID_SCREEN_LOCK_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = + defineAndroidContractEvidence( + 'packages/platform-android/src/runtime.test.ts', + [C.screenLock], + 'Android refuses screen-lock on every kind', + ); export const ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = defineAndroidContractEvidence( 'packages/platform-android/src/runtime.test.ts', @@ -70,7 +76,7 @@ export const TVOS_AUDIO_EVIDENCE: RepositoryEvidence = { /** The Apple owner's one navigation-fact classification, cited by every leaf it refuses. */ export const APPLE_NAVIGATION_FACTS_EVIDENCE: RepositoryEvidence = { path: 'packages/platform-apple/src/runtime.test.ts', - test: 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', + test: 'classifies back/home/app-switcher/screen-lock/orientation/tv-remote/keyboard facts for the %s leaf', }; export const APPLE_ACTION_BUTTON_FACT_EVIDENCE: RepositoryEvidence = { path: 'packages/platform-apple/src/runtime.test.ts', diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index 4131ca25f8..35905a614e 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -127,6 +127,7 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.tvRemote]: () => one(['select']), [PUBLIC_COMMANDS.appSwitcher]: () => one(), [PUBLIC_COMMANDS.actionButton]: () => one(), + [PUBLIC_COMMANDS.screenLock]: () => one(), [PUBLIC_COMMANDS.fold]: () => one(['open']), // -- orchestration (drive to an error response; still scanned) -- diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 2bcbc00342..f445c69853 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -275,6 +275,7 @@ await client.command.tvRemote({ await client.command.appSwitcher(); await client.command.actionButton(); +await client.command.screenLock(); await client.command.fold({ pose: 'open' }); await client.command.fold({ keyframes: [ @@ -300,6 +301,7 @@ Supported command methods: - `orientation` - `appSwitcher` - `actionButton` +- `screenLock` - `fold` - `keyboard` - `clipboard` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d46a5df559..55e8f592fa 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -54,6 +54,7 @@ agent-device orientation portrait agent-device orientation landscape-left agent-device app-switcher agent-device action-button +agent-device screen-lock --platform ios --device agent-device fold closed agent-device fold half-open agent-device fold open @@ -96,6 +97,8 @@ agent-device fold open - A simulator scoped to a non-default set with `--ios-simulator-device-set` is refused before any hinge is touched with `UNSUPPORTED_OPERATION` and `details.reason: "unsupported-device-scope"`. The HID send accepts `--set`, but `devicectl device info displays` and `devicectl device motion hinge-angle` accept only `--device` and resolve a scoped simulator as not found, so the pose could not be read back (ADR 0025). Run `fold` against a simulator in the default set. - `fold` costs one bounded hinge stream per read, and devicectl's smallest stream is five seconds: `closed` and `open` take about ten seconds, `half-open` about sixteen, because the hinge animates and the command waits for it to stop. A hinge whose last reading is some other pose fails with `COMMAND_FAILED` and `reason: fold-pose-unverified`, naming the angle CoreDevice still reports. A hinge seen `half-open` but never at rest fails with `reason: fold-pose-unsettled`, naming the observed and previous angles: the requested category was observed, and what is missing is a pose the hinge holds (#2730). - `action-button` is not a cheap command to loop. On an iPhone 17 Pro Simulator the press itself spent about five seconds inside XCUITest, while `home` and `app-switcher` on the same session took under two seconds each. +- `screen-lock` transitions an iPhone or iPad Simulator to its Lock Screen. It is idempotent and returns `{ action: "screen-lock", state: "locked", message: "Screen locked" }` only after SpringBoard reports the locked state and exposes its Lock Screen date surface. It never means a process mutex, device claim, or runner lease. +- `screen-lock` is unsupported on physical devices, macOS, tvOS, watchOS, visionOS, Android, web, Linux, HarmonyOS, and Vega. Unsupported targets fail with `UNSUPPORTED_OPERATION` before dispatch. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs require an active app in the session. - Commands that need one concrete device refuse to guess: if no `--device`/`--udid`/`--serial` is given and several candidates are equally preferred (for example two booted emulators), the command fails with `AMBIGUOUS_MATCH` and lists them, rather than picking one and returning a successful answer about a device you did not select. Preferences still apply first — virtual over physical, booted over offline — so one booted emulator beside offline ones resolves normally, as does any command running inside an existing session. `devices` lists everything as before. - Commands that omit `--session` use an implicit `default` session scoped to the caller's current git worktree or working directory. This keeps independent local agents from accidentally attaching to each other's default session.