agent-device 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +4 -607
  2. package/dist/src/331.js +3 -3
  3. package/dist/src/425.js +1 -0
  4. package/dist/src/bin.js +28 -28
  5. package/dist/src/core/dispatch.d.ts +2 -0
  6. package/dist/src/core/session-surface.d.ts +3 -0
  7. package/dist/src/core/settings-contract.d.ts +2 -1
  8. package/dist/src/daemon/android-system-dialog.d.ts +11 -0
  9. package/dist/src/daemon/app-log-ios.d.ts +2 -1
  10. package/dist/src/daemon/app-log-process.d.ts +1 -1
  11. package/dist/src/daemon/app-log.d.ts +1 -1
  12. package/dist/src/daemon/context.d.ts +2 -0
  13. package/dist/src/daemon/handlers/interaction-common.d.ts +30 -1
  14. package/dist/src/daemon/handlers/interaction-read.d.ts +14 -0
  15. package/dist/src/daemon/handlers/interaction-touch.d.ts +45 -0
  16. package/dist/src/daemon/handlers/interaction.d.ts +2 -0
  17. package/dist/src/daemon/handlers/record-trace-android.d.ts +18 -0
  18. package/dist/src/daemon/handlers/record-trace-ios.d.ts +52 -0
  19. package/dist/src/daemon/handlers/record-trace-recording.d.ts +32 -0
  20. package/dist/src/daemon/handlers/record-trace.d.ts +2 -7
  21. package/dist/src/daemon/handlers/snapshot-capture.d.ts +11 -4
  22. package/dist/src/daemon/record-trace-errors.d.ts +6 -0
  23. package/dist/src/daemon/recording-gestures.d.ts +3 -0
  24. package/dist/src/daemon/recording-telemetry.d.ts +20 -0
  25. package/dist/src/daemon/recording-timing.d.ts +24 -0
  26. package/dist/src/daemon/request-router.d.ts +6 -0
  27. package/dist/src/daemon/script-utils.d.ts +1 -0
  28. package/dist/src/daemon/snapshot-processing.d.ts +1 -0
  29. package/dist/src/daemon/touch-reference-frame.d.ts +7 -0
  30. package/dist/src/daemon/types.d.ts +65 -11
  31. package/dist/src/daemon.js +62 -36
  32. package/dist/src/platforms/android/index.d.ts +1 -1
  33. package/dist/src/platforms/android/input-actions.d.ts +5 -0
  34. package/dist/src/platforms/android/settings.d.ts +1 -1
  35. package/dist/src/platforms/ios/apps.d.ts +1 -1
  36. package/dist/src/platforms/ios/macos-helper.d.ts +69 -0
  37. package/dist/src/platforms/ios/runner-client.d.ts +2 -2
  38. package/dist/src/platforms/ios/runner-session.d.ts +5 -0
  39. package/dist/src/platforms/ios/runner-xctestrun.d.ts +3 -1
  40. package/dist/src/recording/overlay.d.ts +10 -0
  41. package/dist/src/utils/command-schema.d.ts +2 -0
  42. package/dist/src/utils/interactors.d.ts +8 -8
  43. package/dist/src/utils/snapshot-lines.d.ts +5 -2
  44. package/dist/src/utils/snapshot.d.ts +8 -1
  45. package/dist/src/utils/text-surface.d.ts +19 -0
  46. package/dist/src/utils/video.d.ts +9 -0
  47. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +196 -51
  48. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +133 -0
  49. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +1 -1
  50. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +33 -1
  51. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +4 -6
  52. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +1 -0
  53. package/ios-runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +571 -0
  54. package/ios-runner/AgentDeviceRunner/RecordingScripts/recording-trim.swift +140 -0
  55. package/macos-helper/Package.swift +18 -0
  56. package/macos-helper/Sources/AgentDeviceMacOSHelper/SnapshotTraversal.swift +543 -0
  57. package/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +545 -0
  58. package/package.json +4 -1
  59. package/skills/agent-device/SKILL.md +25 -334
  60. package/skills/agent-device/references/bootstrap-install.md +167 -0
  61. package/skills/agent-device/references/coordinate-system.md +24 -4
  62. package/skills/agent-device/references/debugging.md +115 -0
  63. package/skills/agent-device/references/exploration.md +193 -0
  64. package/skills/agent-device/references/macos-desktop.md +55 -57
  65. package/skills/agent-device/references/remote-tenancy.md +56 -47
  66. package/skills/agent-device/references/verification.md +103 -0
  67. package/dist/src/274.js +0 -1
  68. package/dist/src/daemon/handlers/interaction-fill.d.ts +0 -3
  69. package/dist/src/daemon/handlers/interaction-press.d.ts +0 -3
  70. package/skills/agent-device/references/batching.md +0 -79
  71. package/skills/agent-device/references/logs-and-debug.md +0 -113
  72. package/skills/agent-device/references/perf-metrics.md +0 -53
  73. package/skills/agent-device/references/permissions.md +0 -70
  74. package/skills/agent-device/references/session-management.md +0 -101
  75. package/skills/agent-device/references/snapshot-refs.md +0 -102
  76. package/skills/agent-device/references/video-recording.md +0 -41
@@ -1,6 +1,6 @@
1
1
  export { ensureAdb } from './adb.ts';
2
2
  export { resolveAndroidApp, listAndroidApps, inferAndroidAppName, getAndroidAppState, openAndroidApp, isAmStartError, parseAndroidLaunchComponent, openAndroidDevice, closeAndroidApp, installAndroidInstallablePath, installAndroidInstallablePathAndResolvePackageName, installAndroidApp, reinstallAndroidApp, } from './app-lifecycle.ts';
3
- export { pressAndroid, swipeAndroid, backAndroid, homeAndroid, appSwitcherAndroid, longPressAndroid, typeAndroid, focusAndroid, fillAndroid, scrollAndroid, scrollIntoViewAndroid, } from './input-actions.ts';
3
+ export { pressAndroid, swipeAndroid, backAndroid, homeAndroid, appSwitcherAndroid, longPressAndroid, typeAndroid, focusAndroid, fillAndroid, readAndroidTextAtPoint, scrollAndroid, scrollIntoViewAndroid, getAndroidScreenSize, } from './input-actions.ts';
4
4
  export { type AndroidKeyboardState, getAndroidKeyboardState, dismissAndroidKeyboard, readAndroidClipboardText, writeAndroidClipboardText, } from './device-input-state.ts';
5
5
  export { setAndroidSetting } from './settings.ts';
6
6
  export { pushAndroidNotification } from './notifications.ts';
@@ -10,3 +10,8 @@ export declare function focusAndroid(device: DeviceInfo, x: number, y: number):
10
10
  export declare function fillAndroid(device: DeviceInfo, x: number, y: number, text: string): Promise<void>;
11
11
  export declare function scrollAndroid(device: DeviceInfo, direction: string, amount?: number): Promise<void>;
12
12
  export declare function scrollIntoViewAndroid(device: DeviceInfo, text: string): Promise<void>;
13
+ export declare function getAndroidScreenSize(device: DeviceInfo): Promise<{
14
+ width: number;
15
+ height: number;
16
+ }>;
17
+ export declare function readAndroidTextAtPoint(device: DeviceInfo, x: number, y: number): Promise<string | null>;
@@ -1,3 +1,3 @@
1
1
  import type { DeviceInfo } from '../../utils/device.ts';
2
2
  import { type PermissionSettingOptions } from '../permission-utils.ts';
3
- export declare function setAndroidSetting(device: DeviceInfo, setting: string, state: string, appPackage?: string, options?: PermissionSettingOptions): Promise<void>;
3
+ export declare function setAndroidSetting(device: DeviceInfo, setting: string, state: string, appPackage?: string, options?: PermissionSettingOptions): Promise<Record<string, unknown> | void>;
@@ -29,6 +29,6 @@ export declare function installIosInstallablePath(device: DeviceInfo, installabl
29
29
  export declare function readIosClipboardText(device: DeviceInfo): Promise<string>;
30
30
  export declare function writeIosClipboardText(device: DeviceInfo, text: string): Promise<void>;
31
31
  export declare function pushIosNotification(device: DeviceInfo, bundleId: string, payload: Record<string, unknown>): Promise<void>;
32
- export declare function setIosSetting(device: DeviceInfo, setting: string, state: string, appBundleId?: string, options?: PermissionSettingOptions): Promise<void>;
32
+ export declare function setIosSetting(device: DeviceInfo, setting: string, state: string, appBundleId?: string, options?: PermissionSettingOptions): Promise<Record<string, unknown> | void>;
33
33
  export declare function listIosApps(device: DeviceInfo, filter?: 'user-installed' | 'all'): Promise<IosAppInfo[]>;
34
34
  export declare function listSimulatorApps(device: DeviceInfo): Promise<IosAppInfo[]>;
@@ -0,0 +1,69 @@
1
+ import type { SessionSurface } from '../../core/session-surface.ts';
2
+ export type MacOsPermissionTarget = 'accessibility' | 'screen-recording' | 'input-monitoring';
3
+ export type MacOsSnapshotNode = {
4
+ index: number;
5
+ type?: string;
6
+ role?: string;
7
+ subrole?: string;
8
+ label?: string;
9
+ value?: string;
10
+ identifier?: string;
11
+ rect?: {
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ };
17
+ enabled?: boolean;
18
+ selected?: boolean;
19
+ hittable?: boolean;
20
+ depth?: number;
21
+ parentIndex?: number;
22
+ pid?: number;
23
+ bundleId?: string;
24
+ appName?: string;
25
+ windowTitle?: string;
26
+ surface?: string;
27
+ };
28
+ export declare function resolveMacOsHelperPackageRootFrom(modulePath: string): string;
29
+ export declare function resolveFrontmostMacOsApp(): Promise<{
30
+ bundleId?: string;
31
+ appName?: string;
32
+ pid?: number;
33
+ }>;
34
+ export declare function quitMacOsApp(bundleId: string): Promise<{
35
+ bundleId: string;
36
+ running: boolean;
37
+ terminated: boolean;
38
+ forceTerminated: boolean;
39
+ }>;
40
+ export declare function runMacOsPermissionAction(action: 'grant' | 'reset', target: MacOsPermissionTarget): Promise<{
41
+ target: MacOsPermissionTarget;
42
+ granted: boolean;
43
+ requested: boolean;
44
+ openedSettings: boolean;
45
+ action: 'grant' | 'reset';
46
+ message?: string;
47
+ }>;
48
+ export declare function runMacOsAlertAction(action: 'get' | 'accept' | 'dismiss', options?: {
49
+ bundleId?: string;
50
+ surface?: SessionSurface;
51
+ }): Promise<{
52
+ title?: string;
53
+ role?: string;
54
+ buttons?: string[];
55
+ action?: string;
56
+ bundleId?: string;
57
+ }>;
58
+ export declare function runMacOsSnapshotAction(surface: Exclude<SessionSurface, 'app'>): Promise<{
59
+ surface: Exclude<SessionSurface, 'app'>;
60
+ nodes: MacOsSnapshotNode[];
61
+ truncated: boolean;
62
+ backend: 'macos-helper';
63
+ }>;
64
+ export declare function runMacOsReadTextAction(x: number, y: number, options?: {
65
+ bundleId?: string;
66
+ surface?: SessionSurface;
67
+ }): Promise<{
68
+ text: string;
69
+ }>;
@@ -1,7 +1,7 @@
1
1
  import type { DeviceInfo } from '../../utils/device.ts';
2
2
  import type { ClickButton } from '../../core/click-button.ts';
3
3
  export type RunnerCommand = {
4
- command: 'tap' | 'mouseClick' | 'tapSeries' | 'longPress' | 'drag' | 'dragSeries' | 'type' | 'swipe' | 'findText' | 'snapshot' | 'screenshot' | 'back' | 'home' | 'appSwitcher' | 'alert' | 'pinch' | 'recordStart' | 'recordStop' | 'shutdown';
4
+ command: 'tap' | 'mouseClick' | 'tapSeries' | 'longPress' | 'drag' | 'dragSeries' | 'type' | 'swipe' | 'findText' | 'readText' | 'snapshot' | 'screenshot' | 'back' | 'home' | 'appSwitcher' | 'alert' | 'pinch' | 'recordStart' | 'recordStop' | 'uptime' | 'shutdown';
5
5
  appBundleId?: string;
6
6
  text?: string;
7
7
  action?: 'get' | 'accept' | 'dismiss';
@@ -35,4 +35,4 @@ export declare function runIosRunnerCommand(device: DeviceInfo, command: RunnerC
35
35
  }): Promise<Record<string, unknown>>;
36
36
  export { isRetryableRunnerError, shouldRetryRunnerConnectError, resolveRunnerEarlyExitHint, } from './runner-errors.ts';
37
37
  export { resolveRunnerDestination, resolveRunnerBuildDestination, resolveRunnerMaxConcurrentDestinationsFlag, resolveRunnerSigningBuildSettings, resolveRunnerBundleBuildSettings, assertSafeDerivedCleanup, IOS_RUNNER_CONTAINER_BUNDLE_IDS, } from './runner-xctestrun.ts';
38
- export { stopIosRunnerSession, abortAllIosRunnerSessions, stopAllIosRunnerSessions, } from './runner-session.ts';
38
+ export { getRunnerSessionSnapshot, stopIosRunnerSession, abortAllIosRunnerSessions, stopAllIosRunnerSessions, } from './runner-session.ts';
@@ -2,6 +2,7 @@ import { type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts'
2
2
  import type { DeviceInfo } from '../../utils/device.ts';
3
3
  import type { RunnerCommand } from './runner-client.ts';
4
4
  export type RunnerSession = {
5
+ sessionId: string;
5
6
  device: DeviceInfo;
6
7
  deviceId: string;
7
8
  port: number;
@@ -16,6 +17,10 @@ export declare function ensureRunnerSession(device: DeviceInfo, options: {
16
17
  logPath?: string;
17
18
  traceLogPath?: string;
18
19
  }): Promise<RunnerSession>;
20
+ export declare function getRunnerSessionSnapshot(deviceId: string): {
21
+ sessionId: string;
22
+ alive: boolean;
23
+ } | null;
19
24
  export declare function stopRunnerSession(session: RunnerSession): Promise<void>;
20
25
  export declare function stopIosRunnerSession(deviceId: string): Promise<void>;
21
26
  export declare function abortAllIosRunnerSessions(): Promise<void>;
@@ -4,7 +4,7 @@ export declare const runnerPrepProcesses: Set<import("child_process").ChildProce
4
4
  export declare const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[];
5
5
  type EnsureXctestrunDeps = {
6
6
  findProjectRoot: () => string;
7
- findXctestrun: (root: string) => string | null;
7
+ findXctestrun: (root: string, device?: DeviceInfo) => string | null;
8
8
  xctestrunReferencesProjectRoot: (xctestrunPath: string, projectRoot: string) => boolean;
9
9
  resolveExistingXctestrunProductPaths: (xctestrunPath: string) => string[] | null;
10
10
  repairRunnerProductsIfNeeded: (device: DeviceInfo, productPaths: string[], xctestrunPath: string) => Promise<void>;
@@ -22,6 +22,8 @@ export declare function ensureXctestrun(device: DeviceInfo, options: {
22
22
  traceLogPath?: string;
23
23
  }, deps?: EnsureXctestrunDeps): Promise<string>;
24
24
  export declare function shouldDeleteRunnerDerivedRootEntry(entryName: string): boolean;
25
+ export declare function findXctestrun(root: string, device?: DeviceInfo): string | null;
26
+ export declare function scoreXctestrunCandidate(candidatePath: string, device: DeviceInfo): number;
25
27
  export declare function xctestrunReferencesProjectRoot(xctestrunPath: string, projectRoot: string): boolean;
26
28
  export declare function prepareXctestrunWithEnv(xctestrunPath: string, envVars: Record<string, string>, suffix: string): Promise<{
27
29
  xctestrunPath: string;
@@ -0,0 +1,10 @@
1
+ export declare function getRecordingOverlaySupportWarning(hostPlatform?: NodeJS.Platform): string | undefined;
2
+ export declare function trimRecordingStart(params: {
3
+ videoPath: string;
4
+ trimStartMs: number;
5
+ }): Promise<void>;
6
+ export declare function overlayRecordingTouches(params: {
7
+ videoPath: string;
8
+ telemetryPath: string;
9
+ targetLabel?: string;
10
+ }): Promise<void>;
@@ -54,6 +54,7 @@ export type CliFlags = {
54
54
  appsFilter?: 'user-installed' | 'all';
55
55
  count?: number;
56
56
  fps?: number;
57
+ hideTouches?: boolean;
57
58
  intervalMs?: number;
58
59
  holdMs?: number;
59
60
  jitterPx?: number;
@@ -66,6 +67,7 @@ export type CliFlags = {
66
67
  saveScript?: boolean | string;
67
68
  shutdown?: boolean;
68
69
  relaunch?: boolean;
70
+ surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
69
71
  headless?: boolean;
70
72
  restart?: boolean;
71
73
  noRecord?: boolean;
@@ -15,14 +15,14 @@ type Interactor = {
15
15
  }): Promise<void>;
16
16
  openDevice(): Promise<void>;
17
17
  close(app: string): Promise<void>;
18
- tap(x: number, y: number): Promise<void>;
19
- doubleTap(x: number, y: number): Promise<void>;
20
- swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
21
- longPress(x: number, y: number, durationMs?: number): Promise<void>;
22
- focus(x: number, y: number): Promise<void>;
18
+ tap(x: number, y: number): Promise<Record<string, unknown> | void>;
19
+ doubleTap(x: number, y: number): Promise<Record<string, unknown> | void>;
20
+ swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<Record<string, unknown> | void>;
21
+ longPress(x: number, y: number, durationMs?: number): Promise<Record<string, unknown> | void>;
22
+ focus(x: number, y: number): Promise<Record<string, unknown> | void>;
23
23
  type(text: string): Promise<void>;
24
- fill(x: number, y: number, text: string): Promise<void>;
25
- scroll(direction: string, amount?: number): Promise<void>;
24
+ fill(x: number, y: number, text: string): Promise<Record<string, unknown> | void>;
25
+ scroll(direction: string, amount?: number): Promise<Record<string, unknown> | void>;
26
26
  scrollIntoView(text: string): Promise<{
27
27
  attempts?: number;
28
28
  } | void>;
@@ -32,7 +32,7 @@ type Interactor = {
32
32
  appSwitcher(): Promise<void>;
33
33
  readClipboard(): Promise<string>;
34
34
  writeClipboard(text: string): Promise<void>;
35
- setSetting(setting: string, state: string, appId?: string, options?: PermissionSettingOptions): Promise<void>;
35
+ setSetting(setting: string, state: string, appId?: string, options?: PermissionSettingOptions): Promise<Record<string, unknown> | void>;
36
36
  };
37
37
  export declare function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor;
38
38
  export {};
@@ -5,8 +5,11 @@ type SnapshotDisplayLine = {
5
5
  type: string;
6
6
  text: string;
7
7
  };
8
- export declare function buildSnapshotDisplayLines(nodes: SnapshotNode[]): SnapshotDisplayLine[];
9
- export declare function formatSnapshotLine(node: SnapshotNode, depth: number, hiddenGroup: boolean, normalizedType?: string): string;
8
+ type SnapshotLineFormatOptions = {
9
+ summarizeTextSurfaces?: boolean;
10
+ };
11
+ export declare function buildSnapshotDisplayLines(nodes: SnapshotNode[], options?: SnapshotLineFormatOptions): SnapshotDisplayLine[];
12
+ export declare function formatSnapshotLine(node: SnapshotNode, depth: number, hiddenGroup: boolean, normalizedType?: string, options?: SnapshotLineFormatOptions): string;
10
13
  export declare function displayLabel(node: SnapshotNode, type: string): string;
11
14
  export declare function formatRole(type: string): string;
12
15
  export {};
@@ -14,6 +14,8 @@ export type SnapshotOptions = {
14
14
  export type RawSnapshotNode = {
15
15
  index: number;
16
16
  type?: string;
17
+ role?: string;
18
+ subrole?: string;
17
19
  label?: string;
18
20
  value?: string;
19
21
  identifier?: string;
@@ -23,6 +25,11 @@ export type RawSnapshotNode = {
23
25
  hittable?: boolean;
24
26
  depth?: number;
25
27
  parentIndex?: number;
28
+ pid?: number;
29
+ bundleId?: string;
30
+ appName?: string;
31
+ windowTitle?: string;
32
+ surface?: string;
26
33
  };
27
34
  export type SnapshotNode = RawSnapshotNode & {
28
35
  ref: string;
@@ -31,7 +38,7 @@ export type SnapshotState = {
31
38
  nodes: SnapshotNode[];
32
39
  createdAt: number;
33
40
  truncated?: boolean;
34
- backend?: 'xctest' | 'android';
41
+ backend?: 'xctest' | 'android' | 'macos-helper';
35
42
  };
36
43
  export declare function attachRefs(nodes: RawSnapshotNode[]): SnapshotNode[];
37
44
  export declare function normalizeRef(input: string): string | null;
@@ -0,0 +1,19 @@
1
+ type TextSurfaceNode = {
2
+ type?: string;
3
+ label?: string;
4
+ value?: string;
5
+ identifier?: string;
6
+ role?: string;
7
+ subrole?: string;
8
+ };
9
+ export declare function extractReadableText(node: TextSurfaceNode): string;
10
+ export declare function isLargeTextSurface(node: TextSurfaceNode, displayType?: string): boolean;
11
+ export declare function buildTextPreview(text: string): string;
12
+ export declare function describeTextSurface(node: TextSurfaceNode, displayType?: string): {
13
+ text: string;
14
+ isLargeSurface: boolean;
15
+ shouldSummarize: boolean;
16
+ };
17
+ export declare function shouldSummarizeTextSurface(text: string): boolean;
18
+ export declare function trimText(value: unknown): string;
19
+ export {};
@@ -0,0 +1,9 @@
1
+ export declare function waitForStableFile(filePath: string, options?: {
2
+ pollMs?: number;
3
+ attempts?: number;
4
+ }): Promise<void>;
5
+ export declare function isPlayableVideo(filePath: string): Promise<boolean>;
6
+ export declare function waitForPlayableVideo(filePath: string, options?: {
7
+ pollMs?: number;
8
+ attempts?: number;
9
+ }): Promise<void>;
@@ -3,6 +3,16 @@ import XCTest
3
3
  extension RunnerTests {
4
4
  // MARK: - Main Thread Dispatch
5
5
 
6
+ private func currentUptimeMs() -> Double {
7
+ ProcessInfo.processInfo.systemUptime * 1000
8
+ }
9
+
10
+ private func measureGesture(_ action: () -> Void) -> (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double) {
11
+ let gestureStartUptimeMs = currentUptimeMs()
12
+ action()
13
+ return (gestureStartUptimeMs, currentUptimeMs())
14
+ }
15
+
6
16
  func execute(command: Command) throws -> Response {
7
17
  if Thread.isMainThread {
8
18
  return try executeOnMainSafely(command: command)
@@ -175,7 +185,7 @@ extension RunnerTests {
175
185
  }
176
186
  do {
177
187
  let resolvedOutPath = resolveRecordingOutPath(requestedOutPath)
178
- let fpsLabel = command.fps.map(String.init) ?? "max"
188
+ let fpsLabel = command.fps.map(String.init) ?? String(RunnerTests.defaultRecordingFps)
179
189
  NSLog(
180
190
  "AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@",
181
191
  requestedOutPath,
@@ -204,26 +214,80 @@ extension RunnerTests {
204
214
  activeRecording = nil
205
215
  return Response(ok: false, error: ErrorPayload(message: "failed to stop recording: \(error.localizedDescription)"))
206
216
  }
217
+ case .uptime:
218
+ return Response(
219
+ ok: true,
220
+ data: DataPayload(currentUptimeMs: currentUptimeMs())
221
+ )
207
222
  case .tap:
208
223
  if let text = command.text {
209
224
  if let element = findElement(app: activeApp, text: text) {
210
- element.tap()
211
- return Response(ok: true, data: DataPayload(message: "tapped"))
225
+ let timing = measureGesture {
226
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
227
+ element.tap()
228
+ }
229
+ }
230
+ return Response(
231
+ ok: true,
232
+ data: DataPayload(
233
+ message: "tapped",
234
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
235
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
236
+ )
237
+ )
212
238
  }
213
239
  return Response(ok: false, error: ErrorPayload(message: "element not found"))
214
240
  }
215
241
  if let x = command.x, let y = command.y {
216
- tapAt(app: activeApp, x: x, y: y)
217
- return Response(ok: true, data: DataPayload(message: "tapped"))
242
+ let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
243
+ let timing = measureGesture {
244
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
245
+ tapAt(app: activeApp, x: x, y: y)
246
+ }
247
+ }
248
+ return Response(
249
+ ok: true,
250
+ data: DataPayload(
251
+ message: "tapped",
252
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
253
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
254
+ x: touchFrame.x,
255
+ y: touchFrame.y,
256
+ referenceWidth: touchFrame.referenceWidth,
257
+ referenceHeight: touchFrame.referenceHeight
258
+ )
259
+ )
218
260
  }
219
261
  return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
220
262
  case .mouseClick:
221
263
  guard let x = command.x, let y = command.y else {
222
264
  return Response(ok: false, error: ErrorPayload(message: "mouseClick requires x and y"))
223
265
  }
266
+ let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
224
267
  do {
225
- try mouseClickAt(app: activeApp, x: x, y: y, button: command.button ?? "primary")
226
- return Response(ok: true, data: DataPayload(message: "clicked"))
268
+ var clickError: Error?
269
+ let timing = measureGesture {
270
+ do {
271
+ try mouseClickAt(app: activeApp, x: x, y: y, button: command.button ?? "primary")
272
+ } catch {
273
+ clickError = error
274
+ }
275
+ }
276
+ if let clickError {
277
+ throw clickError
278
+ }
279
+ return Response(
280
+ ok: true,
281
+ data: DataPayload(
282
+ message: "clicked",
283
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
284
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
285
+ x: touchFrame.x,
286
+ y: touchFrame.y,
287
+ referenceWidth: touchFrame.referenceWidth,
288
+ referenceHeight: touchFrame.referenceHeight
289
+ )
290
+ )
227
291
  } catch {
228
292
  return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
229
293
  }
@@ -234,32 +298,95 @@ extension RunnerTests {
234
298
  let count = max(Int(command.count ?? 1), 1)
235
299
  let intervalMs = max(command.intervalMs ?? 0, 0)
236
300
  let doubleTap = command.doubleTap ?? false
301
+ let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
237
302
  if doubleTap {
238
- runSeries(count: count, pauseMs: intervalMs) { _ in
239
- doubleTapAt(app: activeApp, x: x, y: y)
303
+ let timing = measureGesture {
304
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
305
+ runSeries(count: count, pauseMs: intervalMs) { _ in
306
+ doubleTapAt(app: activeApp, x: x, y: y)
307
+ }
308
+ }
240
309
  }
241
- return Response(ok: true, data: DataPayload(message: "tap series"))
310
+ return Response(
311
+ ok: true,
312
+ data: DataPayload(
313
+ message: "tap series",
314
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
315
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
316
+ x: touchFrame.x,
317
+ y: touchFrame.y,
318
+ referenceWidth: touchFrame.referenceWidth,
319
+ referenceHeight: touchFrame.referenceHeight
320
+ )
321
+ )
242
322
  }
243
- runSeries(count: count, pauseMs: intervalMs) { _ in
244
- tapAt(app: activeApp, x: x, y: y)
323
+ let timing = measureGesture {
324
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
325
+ runSeries(count: count, pauseMs: intervalMs) { _ in
326
+ tapAt(app: activeApp, x: x, y: y)
327
+ }
328
+ }
245
329
  }
246
- return Response(ok: true, data: DataPayload(message: "tap series"))
330
+ return Response(
331
+ ok: true,
332
+ data: DataPayload(
333
+ message: "tap series",
334
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
335
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
336
+ x: touchFrame.x,
337
+ y: touchFrame.y,
338
+ referenceWidth: touchFrame.referenceWidth,
339
+ referenceHeight: touchFrame.referenceHeight
340
+ )
341
+ )
247
342
  case .longPress:
248
343
  guard let x = command.x, let y = command.y else {
249
344
  return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
250
345
  }
251
346
  let duration = (command.durationMs ?? 800) / 1000.0
252
- longPressAt(app: activeApp, x: x, y: y, duration: duration)
253
- return Response(ok: true, data: DataPayload(message: "long pressed"))
347
+ let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
348
+ let timing = measureGesture {
349
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
350
+ longPressAt(app: activeApp, x: x, y: y, duration: duration)
351
+ }
352
+ }
353
+ return Response(
354
+ ok: true,
355
+ data: DataPayload(
356
+ message: "long pressed",
357
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
358
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
359
+ x: touchFrame.x,
360
+ y: touchFrame.y,
361
+ referenceWidth: touchFrame.referenceWidth,
362
+ referenceHeight: touchFrame.referenceHeight
363
+ )
364
+ )
254
365
  case .drag:
255
366
  guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
256
367
  return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
257
368
  }
258
369
  let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
259
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
260
- dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
370
+ let dragFrame = resolvedDragVisualizationFrame(app: activeApp, x: x, y: y, x2: x2, y2: y2)
371
+ let timing = measureGesture {
372
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
373
+ dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
374
+ }
261
375
  }
262
- return Response(ok: true, data: DataPayload(message: "dragged"))
376
+ return Response(
377
+ ok: true,
378
+ data: DataPayload(
379
+ message: "dragged",
380
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
381
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
382
+ x: dragFrame.x,
383
+ y: dragFrame.y,
384
+ x2: dragFrame.x2,
385
+ y2: dragFrame.y2,
386
+ referenceWidth: dragFrame.referenceWidth,
387
+ referenceHeight: dragFrame.referenceHeight
388
+ )
389
+ )
263
390
  case .dragSeries:
264
391
  guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
265
392
  return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))
@@ -271,17 +398,26 @@ extension RunnerTests {
271
398
  return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
272
399
  }
273
400
  let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
274
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
275
- runSeries(count: count, pauseMs: pauseMs) { idx in
276
- let reverse = pattern == "ping-pong" && (idx % 2 == 1)
277
- if reverse {
278
- dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
279
- } else {
280
- dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
401
+ let timing = measureGesture {
402
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
403
+ runSeries(count: count, pauseMs: pauseMs) { idx in
404
+ let reverse = pattern == "ping-pong" && (idx % 2 == 1)
405
+ if reverse {
406
+ dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
407
+ } else {
408
+ dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
409
+ }
281
410
  }
282
411
  }
283
412
  }
284
- return Response(ok: true, data: DataPayload(message: "drag series"))
413
+ return Response(
414
+ ok: true,
415
+ data: DataPayload(
416
+ message: "drag series",
417
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
418
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
419
+ )
420
+ )
285
421
  case .type:
286
422
  guard let text = command.text else {
287
423
  return Response(ok: false, error: ErrorPayload(message: "type requires text"))
@@ -304,16 +440,36 @@ extension RunnerTests {
304
440
  guard let direction = command.direction else {
305
441
  return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
306
442
  }
307
- withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
308
- swipe(app: activeApp, direction: direction)
443
+ let referenceFrame = resolvedGestureReferenceFrame(app: activeApp)
444
+ let timing = measureGesture {
445
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
446
+ swipe(app: activeApp, direction: direction)
447
+ }
309
448
  }
310
- return Response(ok: true, data: DataPayload(message: "swiped"))
449
+ return Response(
450
+ ok: true,
451
+ data: DataPayload(
452
+ message: "swiped",
453
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
454
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
455
+ referenceWidth: referenceFrame.referenceWidth,
456
+ referenceHeight: referenceFrame.referenceHeight
457
+ )
458
+ )
311
459
  case .findText:
312
460
  guard let text = command.text else {
313
461
  return Response(ok: false, error: ErrorPayload(message: "findText requires text"))
314
462
  }
315
463
  let found = findElement(app: activeApp, text: text) != nil
316
464
  return Response(ok: true, data: DataPayload(found: found))
465
+ case .readText:
466
+ guard let x = command.x, let y = command.y else {
467
+ return Response(ok: false, error: ErrorPayload(message: "readText requires x and y"))
468
+ }
469
+ guard let text = readTextAt(app: activeApp, x: x, y: y) else {
470
+ return Response(ok: false, error: ErrorPayload(message: "readText did not resolve text"))
471
+ }
472
+ return Response(ok: true, data: DataPayload(text: text))
317
473
  case .snapshot:
318
474
  let options = SnapshotOptions(
319
475
  interactiveOnly: command.interactiveOnly ?? false,
@@ -358,30 +514,15 @@ extension RunnerTests {
358
514
  if tapNavigationBack(app: activeApp) {
359
515
  return Response(ok: true, data: DataPayload(message: "back"))
360
516
  }
361
- #if os(macOS)
362
- return Response(ok: false, error: ErrorPayload(message: "back button is not available on macOS"))
363
- #else
364
517
  performBackGesture(app: activeApp)
365
518
  return Response(ok: true, data: DataPayload(message: "back"))
366
- #endif
367
519
  case .home:
368
- #if os(macOS)
369
- return Response(ok: false, error: ErrorPayload(message: "home is not supported on macOS"))
370
- #else
371
520
  pressHomeButton()
372
521
  return Response(ok: true, data: DataPayload(message: "home"))
373
- #endif
374
522
  case .appSwitcher:
375
- #if os(macOS)
376
- return Response(ok: false, error: ErrorPayload(message: "appSwitcher is not supported on macOS"))
377
- #else
378
523
  performAppSwitcherGesture(app: activeApp)
379
524
  return Response(ok: true, data: DataPayload(message: "appSwitcher"))
380
- #endif
381
525
  case .alert:
382
- #if os(macOS)
383
- return Response(ok: false, error: ErrorPayload(message: "alert is not supported on macOS"))
384
- #else
385
526
  let action = (command.action ?? "get").lowercased()
386
527
  let alert = activeApp.alerts.firstMatch
387
528
  if !alert.exists {
@@ -399,17 +540,21 @@ extension RunnerTests {
399
540
  }
400
541
  let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
401
542
  return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
402
- #endif
403
543
  case .pinch:
404
- #if os(macOS)
405
- return Response(ok: false, error: ErrorPayload(message: "pinch is not supported on macOS"))
406
- #else
407
544
  guard let scale = command.scale, scale > 0 else {
408
545
  return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
409
546
  }
410
- pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
411
- return Response(ok: true, data: DataPayload(message: "pinched"))
412
- #endif
547
+ let timing = measureGesture {
548
+ pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
549
+ }
550
+ return Response(
551
+ ok: true,
552
+ data: DataPayload(
553
+ message: "pinched",
554
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
555
+ gestureEndUptimeMs: timing.gestureEndUptimeMs
556
+ )
557
+ )
413
558
  }
414
559
  }
415
560
  }