From 8d881fc122443bb06fb09ebb80ae62f4231213cb Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 13:48:57 -0700 Subject: [PATCH 01/10] feat(apple): add verified simulator screen lock --- .../RunnerTests+CommandExecution.swift | 2 + .../RunnerTests+CommandJournal.swift | 2 +- .../RunnerTests+Models.swift | 4 +- .../RunnerTests+ScreenLock.swift | 142 ++++++++++++++++++ .../UnitTests/RunnerTests+ModelsTests.swift | 6 +- .../RunnerTests+ScreenLockTests.swift | 93 ++++++++++++ contracts/fixtures/runner-requests.json | 1 + .../src/__tests__/command-result.test.ts | 4 + .../command-registry/src/command-result.ts | 2 + packages/command-registry/src/registry.ts | 11 ++ packages/contracts/src/client-system.ts | 2 + packages/contracts/src/facades/client.ts | 1 + .../src/interactor-operation-catalog.ts | 5 + .../interactor-operation-conformance.test.ts | 1 + packages/contracts/src/interactor-types.ts | 6 + packages/contracts/src/navigation.ts | 7 + .../src/platform-runtime-operations.ts | 1 + .../contracts/src/runtime-operation-names.ts | 1 + .../src/system-button-runtime.test.ts | 2 + .../contracts/src/system-button-runtime.ts | 3 +- packages/platform-android/src/runtime.test.ts | 19 +++ .../interactor-runner-provider.test.ts | 1 + .../src/__tests__/runner-requests.test.ts | 1 + packages/platform-apple/src/interactor.ts | 7 + .../platform-apple/src/navigation/runtime.ts | 16 ++ packages/platform-apple/src/runner-demand.ts | 1 + .../__tests__/runner-command-traits.test.ts | 1 + .../src/runner/runner-command-traits.ts | 1 + .../src/runner/runner-contract.ts | 1 + packages/platform-apple/src/runtime.test.ts | 12 ++ packages/platform-linux/src/runtime.test.ts | 2 + packages/platform-web/src/runtime.test.ts | 1 + .../test-utils/property-arbitraries.ts | 1 + src/agent-device-client.ts | 2 + src/client/client-types.ts | 2 + src/commands/system/index.test.ts | 12 +- src/commands/system/index.ts | 15 ++ src/commands/system/output.ts | 1 + .../__tests__/system-button-runtime.test.ts | 17 ++- src/daemon/system-button-runtime.ts | 9 +- src/mcp/command-output-schemas.ts | 8 + .../command-coverage/declarations.ts | 32 ++++ test/integration/command-coverage/evidence.ts | 6 + .../apple-platform-output-guard.test.ts | 1 + website/docs/docs/client-api.md | 2 + website/docs/docs/commands.md | 3 + 46 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift 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..6a76ea71c0 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 @@ -253,7 +254,7 @@ extension Command { case .recordStop, .uptime, .terminate, .targetReset, .shutdown: return .runnerLifecycle - case .actionButton: + case .actionButton, .screenLock: return .presentedSurfaceMutation case .querySelector: @@ -389,6 +390,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..1836a5821b --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -0,0 +1,142 @@ +import Darwin +import XCTest + +extension RunnerTests { + private static let screenLockStateNotification = "com.apple.springboard.lockstate" + private static let screenLockVerificationTimeout: TimeInterval = 5 + private 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) + let 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) + ) + } + ) + #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 + ) -> Response { + switch readState() { + case .success(true): + return screenLockVisibleResponse(verifyVisibleSurface) + case .failure(let response): + return response + case .success(false): + break + } + + if let dispatchFailure = dispatch() { return dispatchFailure } + + while shouldContinue() { + switch readState() { + case .success(true): + return screenLockVisibleResponse(verifyVisibleSurface) + case .failure(let response): + return response + case .success(false): + wait() + } + } + + 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(_ verifyVisibleSurface: () -> Bool) -> Response { + guard verifyVisibleSurface() else { + return Response( + ok: false, + error: ErrorPayload( + code: "COMMAND_FAILED", + message: "SpringBoard reported a locked state, but the Lock Screen surface was not visible" + ) + ) + } + return Response(ok: true, data: DataPayload(message: "screenLock", state: "locked")) + } + + 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") + return springboard.exists && !springboard.frame.isEmpty + } + + private 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..d7b199e787 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: .presentedSurface, 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..a68f39856f --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -0,0 +1,93 @@ +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) && targetEnvironment(simulator) +extension RunnerTests { + 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] + 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 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) + } +} +#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..85305920cc 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 its Lock Screen state. */ +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..0950dee13b 100644 --- a/packages/contracts/src/system-button-runtime.ts +++ b/packages/contracts/src/system-button-runtime.ts @@ -9,7 +9,7 @@ import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; * 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 +18,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..c9d6d7510a 100644 --- a/packages/platform-apple/src/navigation/runtime.ts +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -147,6 +147,21 @@ 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; + const os = resolveDeviceAppleOs(device); + return os === 'ios' || os === 'ipados' ? 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 27522d3c1b..d2d7976629 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..1cf5077005 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -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..7f5f308610 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -115,6 +115,7 @@ import type { PrepareCommandOptions, PressOptions, ReactNativeCommandOptions, + ScreenLockCommandOptions, RecordOptions, ReplayRunOptions, ReplayTestOptions, @@ -169,6 +170,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..b978207a4a 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. 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,17 @@ 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 means SpringBoard reported the Lock Screen state.', + }, + run: (client, input) => client.command.screenLock(input), + cliOutputFormatter: systemCliOutputFormatters['screen-lock'], +}); + const tvRemoteCommandFacet = defineCommandFacet({ name: TV_REMOTE_COMMAND_NAME, text: { @@ -405,6 +419,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..2b97bd900a 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'; @@ -28,6 +29,7 @@ type SystemButtonCommandRow = Readonly<{ use: SystemButtonUse; /** The success text the press reports; the response carries nothing else by design. */ message: string; + state?: 'locked'; }>; /** @@ -40,6 +42,7 @@ 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..14acd0a9ef 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', 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 2b4ad186af..ca111de312 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" }` only after both SpringBoard's lock state and a visible, non-empty SpringBoard surface agree. 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. From 768c19306f94c3ded2a5563bfedc225ebe7f1315 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Sun, 27 Sep 2026 15:11:05 -0700 Subject: [PATCH 02/10] fix(apple): import Darwin notifications for screen lock --- .../AgentDeviceRunnerUITests-Bridging-Header.h | 2 ++ 1 file changed, 2 insertions(+) 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" From 0518917ee1c8927efb56738afeb96d67fda78008 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 14:14:11 -0700 Subject: [PATCH 03/10] fix(apple): harden screen lock verification --- .../RunnerTests+Models.swift | 12 ++++- .../RunnerTests+ScreenLock.swift | 43 +++++++++++----- .../UnitTests/RunnerTests+ModelsTests.swift | 2 +- .../RunnerTests+ScreenLockTests.swift | 49 +++++++++++++++++++ .../contracts/src/system-button-runtime.ts | 3 +- .../platform-apple/src/navigation/runtime.ts | 4 +- packages/platform-apple/src/runtime.test.ts | 2 +- src/daemon/system-button-runtime.ts | 4 +- website/docs/docs/commands.md | 2 +- 9 files changed, 100 insertions(+), 21 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 6a76ea71c0..9556ec9a43 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -150,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. @@ -254,9 +260,13 @@ extension Command { case .recordStop, .uptime, .terminate, .targetReset, .shutdown: return .runnerLifecycle - case .actionButton, .screenLock: + case .actionButton: return .presentedSurfaceMutation + case .screenLock: + // Unsupported platform leaves must fail before bringing the session app forward. + return .systemStateMutation + case .querySelector: return .selectorResolution diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift index 1836a5821b..2ed5379b4c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -49,7 +49,7 @@ extension RunnerTests { ) -> Response { switch readState() { case .success(true): - return screenLockVisibleResponse(verifyVisibleSurface) + return screenLockVisibleResponse(verifyVisibleSurface, shouldContinue: shouldContinue, wait: wait) case .failure(let response): return response case .success(false): @@ -61,7 +61,7 @@ extension RunnerTests { while shouldContinue() { switch readState() { case .success(true): - return screenLockVisibleResponse(verifyVisibleSurface) + return screenLockVisibleResponse(verifyVisibleSurface, shouldContinue: shouldContinue, wait: wait) case .failure(let response): return response case .success(false): @@ -69,6 +69,17 @@ extension RunnerTests { } } + // 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(verifyVisibleSurface, shouldContinue: shouldContinue, wait: wait) + case .failure(let response): + return response + case .success(false): + break + } + return Response( ok: false, error: ErrorPayload( @@ -79,17 +90,25 @@ extension RunnerTests { ) } - private func screenLockVisibleResponse(_ verifyVisibleSurface: () -> Bool) -> Response { - guard verifyVisibleSurface() else { - 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 screenLockVisibleResponse( + _ verifyVisibleSurface: () -> Bool, + shouldContinue: () -> Bool, + wait: () -> Void + ) -> Response { + while true { + if verifyVisibleSurface() { + return Response(ok: true, data: DataPayload(message: "Screen locked", state: "locked")) + } + guard shouldContinue() else { break } + wait() } - return Response(ok: true, data: DataPayload(message: "screenLock", state: "locked")) + 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? { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index d7b199e787..af8640c739 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -271,7 +271,7 @@ extension RunnerTests { ), ( .screenLock, - expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true) + 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 index a68f39856f..4d4f275df0 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -73,6 +73,23 @@ extension RunnerTests { 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 testScreenLockTimesOutWhileSimulatorIsStillBooting() { var dispatches = 0 let response = executeScreenLockTransition( @@ -89,5 +106,37 @@ extension RunnerTests { 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, 3) + } + + 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") + } } #endif diff --git a/packages/contracts/src/system-button-runtime.ts b/packages/contracts/src/system-button-runtime.ts index 0950dee13b..0c5a927382 100644 --- a/packages/contracts/src/system-button-runtime.ts +++ b/packages/contracts/src/system-button-runtime.ts @@ -5,7 +5,8 @@ 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. */ diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts index c9d6d7510a..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, @@ -159,8 +160,7 @@ const screenLockOsUnavailable = Object.freeze({ } as const); function appleScreenLockFact(device: DeviceInfo): RuntimeOperationFact { if (device.kind !== 'simulator') return screenLockKindUnavailable; - const os = resolveDeviceAppleOs(device); - return os === 'ios' || os === 'ipados' ? available : screenLockOsUnavailable; + return isHandheldAppleSimulator(device) ? available : screenLockOsUnavailable; } /** * The leaf reading is {@link hasAppleActionButton}, the same rule a provider owner reads; what this diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 1cf5077005..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, diff --git a/src/daemon/system-button-runtime.ts b/src/daemon/system-button-runtime.ts index 2b97bd900a..0928c75315 100644 --- a/src/daemon/system-button-runtime.ts +++ b/src/daemon/system-button-runtime.ts @@ -27,7 +27,7 @@ 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'; }>; @@ -35,7 +35,7 @@ type SystemButtonCommandRow = Readonly<{ /** * 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 = { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ca111de312..815ceb3599 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -97,7 +97,7 @@ 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" }` only after both SpringBoard's lock state and a visible, non-empty SpringBoard surface agree. It never means a process mutex, device claim, or runner lease. +- `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 both SpringBoard's lock state and a visible, non-empty SpringBoard surface agree. 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. From c91ac5caca59892e99cf44a5d16a50dbdb39900e Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:24:52 -0700 Subject: [PATCH 04/10] fix(apple): recheck lock state while verifying surface --- .../RunnerTests+ScreenLock.swift | 38 +++++++++++++++++-- .../RunnerTests+ScreenLockTests.swift | 20 +++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift index 2ed5379b4c..5856b2cbcc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -49,7 +49,12 @@ extension RunnerTests { ) -> Response { switch readState() { case .success(true): - return screenLockVisibleResponse(verifyVisibleSurface, shouldContinue: shouldContinue, wait: wait) + return screenLockVisibleResponse( + readState: readState, + verifyVisibleSurface: verifyVisibleSurface, + shouldContinue: shouldContinue, + wait: wait + ) case .failure(let response): return response case .success(false): @@ -61,7 +66,12 @@ extension RunnerTests { while shouldContinue() { switch readState() { case .success(true): - return screenLockVisibleResponse(verifyVisibleSurface, shouldContinue: shouldContinue, wait: wait) + return screenLockVisibleResponse( + readState: readState, + verifyVisibleSurface: verifyVisibleSurface, + shouldContinue: shouldContinue, + wait: wait + ) case .failure(let response): return response case .success(false): @@ -73,7 +83,12 @@ extension RunnerTests { // deadline. Read once more before reporting timeout so a completed transition is not lost. switch readState() { case .success(true): - return screenLockVisibleResponse(verifyVisibleSurface, shouldContinue: shouldContinue, wait: wait) + return screenLockVisibleResponse( + readState: readState, + verifyVisibleSurface: verifyVisibleSurface, + shouldContinue: shouldContinue, + wait: wait + ) case .failure(let response): return response case .success(false): @@ -91,11 +106,26 @@ extension RunnerTests { } private func screenLockVisibleResponse( - _ verifyVisibleSurface: () -> Bool, + 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")) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift index 4d4f275df0..2591d82c68 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -20,7 +20,7 @@ extension RunnerTests { } func testScreenLockWaitsForTheVerifiedTransition() { - var reads = [false, false, true] + var reads = [false, false, true, true] var dispatches = 0 var waits = 0 let response = executeScreenLockTransition( @@ -90,6 +90,22 @@ extension RunnerTests { 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( @@ -112,7 +128,7 @@ extension RunnerTests { let response = executeScreenLockTransition( readState: { reads += 1 - return .success(reads == 3) + return .success(reads >= 3) }, dispatch: { nil }, verifyVisibleSurface: { true }, From 756dc1363b2c4deb9ba5e540f436681996f4619b Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 15:31:50 -0700 Subject: [PATCH 05/10] test(apple): account for lock state verification read --- .../UnitTests/RunnerTests+ScreenLockTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift index 2591d82c68..9e72333cd6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -136,7 +136,7 @@ extension RunnerTests { wait: {} ) XCTAssertTrue(response.ok) - XCTAssertEqual(reads, 3) + XCTAssertEqual(reads, 4) } func testScreenLockPropagatesLockStateReadFailure() { From bd45e6c24e2ff1dcd11b1ef3479c4554420e44b2 Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 16:16:16 -0700 Subject: [PATCH 06/10] fix(apple): verify visible Lock Screen surface --- .../RunnerTests+ScreenLock.swift | 5 ++++- .../UnitTests/RunnerTests+ScreenLockTests.swift | 12 ++++++++++++ packages/contracts/src/navigation.ts | 2 +- src/commands/system/index.ts | 5 +++-- website/docs/docs/commands.md | 2 +- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift index 5856b2cbcc..31b7f6be98 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -159,7 +159,10 @@ extension RunnerTests { private func verifyLockScreenSurface() -> Bool { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") - return springboard.exists && !springboard.frame.isEmpty + let dateView = springboard.descendants(matching: .any) + .matching(identifier: "lockscreen-date-view") + .firstMatch + return springboard.exists && dateView.exists && !dateView.frame.isEmpty } private func currentScreenLockState() -> ScreenLockStateRead { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift index 9e72333cd6..b02fd13c82 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -2,6 +2,18 @@ import XCTest #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) && targetEnvironment(simulator) extension RunnerTests { + func testScreenLockReportsSuccessOnlyForLockScreenSpecificSurface() { + 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 + XCTAssertTrue(dateView.exists) + XCTAssertFalse(dateView.frame.isEmpty) + } + func testScreenLockIsIdempotentWhenAlreadyLocked() { var dispatches = 0 let response = executeScreenLockTransition( diff --git a/packages/contracts/src/navigation.ts b/packages/contracts/src/navigation.ts index 85305920cc..47aba197b6 100644 --- a/packages/contracts/src/navigation.ts +++ b/packages/contracts/src/navigation.ts @@ -82,7 +82,7 @@ export type ActionButtonCommandResult = { message: string; }; -/** `screen-lock` succeeds only after the selected Simulator reports its Lock Screen state. */ +/** `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'; diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index b978207a4a..579ccc996d 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -83,7 +83,7 @@ const clipboardCommandDescription = 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. This controls simulated screen state; it is unrelated to process mutexes, device claims, or runner leases.'; + '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.'; @@ -389,7 +389,8 @@ const screenLockCommandFacet = defineParameterlessCommandFacet({ description: screenLockCommandDescription, text: { summary: 'Lock the iPhone or iPad Simulator screen', - cliDetail: 'Simulator-only. Success means SpringBoard reported the Lock Screen state.', + 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'], diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 815ceb3599..3c10a5360a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -97,7 +97,7 @@ 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 both SpringBoard's lock state and a visible, non-empty SpringBoard surface agree. It never means a process mutex, device claim, or runner lease. +- `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. From 99e9c475f1f9a6203461e2f57ca0a08b72dfe45b Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 16:28:35 -0700 Subject: [PATCH 07/10] fix(apple): start lock verification after dispatch --- .../RunnerTests+ScreenLock.swift | 9 +++++-- .../RunnerTests+ScreenLockTests.swift | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift index 31b7f6be98..6374e72de5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -11,7 +11,7 @@ extension RunnerTests { /// The pre-read makes the operation idempotent; a dispatch alone is never reported as success. func executeScreenLockCommand() -> Response { #if os(iOS) && targetEnvironment(simulator) - let deadline = Date().addingTimeInterval(Self.screenLockVerificationTimeout) + var deadline = Date().addingTimeInterval(Self.screenLockVerificationTimeout) return executeScreenLockTransition( readState: currentScreenLockState, dispatch: dispatchScreenLock, @@ -21,6 +21,9 @@ extension RunnerTests { RunLoop.current.run( until: Date().addingTimeInterval(Self.screenLockPollInterval) ) + }, + startVerificationWindow: { + deadline = Date().addingTimeInterval(Self.screenLockVerificationTimeout) } ) #else @@ -45,7 +48,8 @@ extension RunnerTests { dispatch: () -> Response?, verifyVisibleSurface: () -> Bool, shouldContinue: () -> Bool, - wait: () -> Void + wait: () -> Void, + startVerificationWindow: () -> Void = {} ) -> Response { switch readState() { case .success(true): @@ -62,6 +66,7 @@ extension RunnerTests { } if let dispatchFailure = dispatch() { return dispatchFailure } + startVerificationWindow() while shouldContinue() { switch readState() { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift index b02fd13c82..1603f43af5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -2,6 +2,30 @@ 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() { let response = executeScreenLockCommand() From 2d1b117310f7900e7e57bb0f6447a6c1b79bffaa Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 16:36:47 -0700 Subject: [PATCH 08/10] fix(apple): preserve screen lock verification and cleanup --- .../RunnerTests+ScreenLock.swift | 7 +++-- .../RunnerTests+ScreenLockTests.swift | 31 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift index 6374e72de5..3158b8a114 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -167,10 +167,13 @@ extension RunnerTests { let dateView = springboard.descendants(matching: .any) .matching(identifier: "lockscreen-date-view") .firstMatch - return springboard.exists && dateView.exists && !dateView.frame.isEmpty + let coverSheet = springboard.windows.matching(identifier: "SBCoverSheetWindow").firstMatch + return springboard.exists + && ((dateView.exists && !dateView.frame.isEmpty) + || (coverSheet.exists && !coverSheet.frame.isEmpty)) } - private func currentScreenLockState() -> ScreenLockStateRead { + func currentScreenLockState() -> ScreenLockStateRead { var token: Int32 = 0 let registerStatus = notify_register_check(Self.screenLockStateNotification, &token) guard registerStatus == NOTIFY_STATUS_OK else { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift index 1603f43af5..022fb1c43b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -27,6 +27,8 @@ extension RunnerTests { } func testScreenLockReportsSuccessOnlyForLockScreenSpecificSurface() { + defer { unlockSimulatorScreenIfNeeded() } + let response = executeScreenLockCommand() XCTAssertTrue(response.ok, response.error?.message ?? "Expected verified screen lock") @@ -34,8 +36,12 @@ extension RunnerTests { let dateView = springboard.descendants(matching: .any) .matching(identifier: "lockscreen-date-view") .firstMatch - XCTAssertTrue(dateView.exists) - XCTAssertFalse(dateView.frame.isEmpty) + let coverSheet = springboard.windows.matching(identifier: "SBCoverSheetWindow").firstMatch + XCTAssertTrue( + (dateView.exists && !dateView.frame.isEmpty) + || (coverSheet.exists && !coverSheet.frame.isEmpty), + springboard.debugDescription + ) } func testScreenLockIsIdempotentWhenAlreadyLocked() { @@ -190,5 +196,26 @@ extension RunnerTests { 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(5) + while Date() < deadline { + if case .success(false) = currentScreenLockState() { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + XCTFail("The screen-lock integration test could not restore the Simulator to unlocked state") + } } #endif From 272f8dc90cf29610a50dbfeff75419bc8808762a Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 16:45:24 -0700 Subject: [PATCH 09/10] test: align screen-lock coverage evidence --- src/client/client-types.ts | 6 ------ test/integration/command-coverage/evidence.ts | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 7f5f308610..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'; diff --git a/test/integration/command-coverage/evidence.ts b/test/integration/command-coverage/evidence.ts index 14acd0a9ef..4fd85c1667 100644 --- a/test/integration/command-coverage/evidence.ts +++ b/test/integration/command-coverage/evidence.ts @@ -76,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', From 99505951fd4805671710ac6eceaad2a0e8cdcb6c Mon Sep 17 00:00:00 2001 From: Christopher Sarkissian Date: Mon, 28 Sep 2026 17:08:30 -0700 Subject: [PATCH 10/10] test(apple): clarify screen-lock simulator cleanup --- .../RunnerTests+ScreenLock.swift | 4 ++-- .../UnitTests/RunnerTests+ScreenLockTests.swift | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift index 3158b8a114..4909d2f7c9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenLock.swift @@ -3,8 +3,8 @@ import XCTest extension RunnerTests { private static let screenLockStateNotification = "com.apple.springboard.lockstate" - private static let screenLockVerificationTimeout: TimeInterval = 5 - private static let screenLockPollInterval: TimeInterval = 0.05 + 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. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift index 022fb1c43b..42d06e3a21 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScreenLockTests.swift @@ -210,10 +210,21 @@ extension RunnerTests { let end = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)) start.press(forDuration: 0.1, thenDragTo: end) - let deadline = Date().addingTimeInterval(5) + let deadline = Date().addingTimeInterval(Self.screenLockVerificationTimeout) while Date() < deadline { if case .success(false) = currentScreenLockState() { return } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + 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") }