agent-device 0.16.6 → 0.16.8

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 (41) hide show
  1. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.6.apk → agent-device-android-multitouch-helper-0.16.8.apk} +0 -0
  2. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.8.apk.sha256 +1 -0
  3. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.6.manifest.json → agent-device-android-multitouch-helper-0.16.8.manifest.json} +4 -4
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.6.apk → agent-device-android-snapshot-helper-0.16.8.apk} +0 -0
  5. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.8.apk.sha256 +1 -0
  6. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.6.manifest.json → agent-device-android-snapshot-helper-0.16.8.manifest.json} +6 -6
  7. package/dist/src/1352.js +1 -1
  8. package/dist/src/2415.js +29 -29
  9. package/dist/src/2805.js +1 -1
  10. package/dist/src/6232.js +1 -0
  11. package/dist/src/7455.js +1 -0
  12. package/dist/src/8699.js +1 -1
  13. package/dist/src/940.js +1 -1
  14. package/dist/src/9471.js +1 -1
  15. package/dist/src/9533.js +1 -1
  16. package/dist/src/9542.js +1 -1
  17. package/dist/src/9818.js +1 -1
  18. package/dist/src/android-adb.d.ts +2 -0
  19. package/dist/src/android-snapshot-helper.d.ts +2 -0
  20. package/dist/src/args.js +5 -4
  21. package/dist/src/cli.js +6 -6
  22. package/dist/src/command-metadata.js +1 -1
  23. package/dist/src/find.js +1 -1
  24. package/dist/src/generic.js +10 -7
  25. package/dist/src/interaction.js +1 -1
  26. package/dist/src/react-native.js +1 -1
  27. package/dist/src/record-trace.js +3 -3
  28. package/dist/src/selector-runtime.js +1 -1
  29. package/dist/src/session.js +9 -9
  30. package/dist/src/snapshot.js +2 -2
  31. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +20 -6
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +178 -74
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -33
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +71 -1
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +80 -10
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +34 -6
  37. package/package.json +4 -6
  38. package/server.json +2 -2
  39. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.6.apk.sha256 +0 -1
  40. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.6.apk.sha256 +0 -1
  41. package/dist/src/5186.js +0 -1
@@ -34,6 +34,74 @@ enum CommandType: String, Codable {
34
34
  case shutdown
35
35
  }
36
36
 
37
+ /// Runner command traits — see CONTEXT.md ("Runner command traits").
38
+ ///
39
+ /// Single source of truth for how the runner classifies a command across three
40
+ /// independent axes, replacing the three hand-maintained switches that used to live
41
+ /// in RunnerTests+Lifecycle.swift (isInteractionCommand / isReadOnlyCommand /
42
+ /// isRunnerLifecycleCommand). The classification is load-bearing for ADR-0002 session
43
+ /// invalidation: `readOnly` gates the retry that nulls currentApp/currentBundleId.
44
+ struct CommandTraits {
45
+ /// Whether the command needs the foreground-guard + stabilization preflight before running.
46
+ let isInteraction: Bool
47
+ /// Whether the command is eligible for the session-invalidating retry.
48
+ /// `.conditional` is resolved against the request (alert is read-only only for its `get` action).
49
+ let readOnly: ReadOnly
50
+ /// Whether the command skips the app-activation preflight entirely.
51
+ let isLifecycle: Bool
52
+
53
+ enum ReadOnly {
54
+ case always
55
+ case never
56
+ /// Alert-only today. Resolved in `isReadOnlyCommand` with alert's rule (read-only for the
57
+ /// `get` action, mutating otherwise). A new `.conditional` command would inherit that rule
58
+ /// until the resolver is generalized — give it explicit handling there if its semantics differ.
59
+ case conditional
60
+ }
61
+ }
62
+
63
+ extension CommandType {
64
+ /// The classification for this command. Exhaustive by construction: a new CommandType
65
+ /// cannot compile without being classified here, so commands can no longer silently drift
66
+ /// out of classification the way the parallel switches allowed.
67
+ var traits: CommandTraits {
68
+ switch self {
69
+ // Interaction commands: require the foreground-guard + stabilization preflight.
70
+ // tapSeries/dragSeries are the series forms of tap/drag; keyboardReturn is the sibling
71
+ // of keyboardDismiss — all three were missing from the historical switch (drift the
72
+ // table now prevents) and are classified as interactions here.
73
+ case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe,
74
+ .back, .backInApp, .backSystem, .rotate, .appSwitcher,
75
+ .keyboardDismiss, .keyboardReturn, .pinch, .rotateGesture, .transformGesture:
76
+ return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)
77
+
78
+ // Read-only reads: eligible for the session-invalidating retry.
79
+ case .interactionFrame, .findText, .readText, .snapshot:
80
+ return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: false)
81
+
82
+ // Screenshot is both a read and a runner-lifecycle command (skips app-activation preflight).
83
+ case .screenshot:
84
+ return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
85
+
86
+ // Alert is read-only only for its `get` action (resolved by isReadOnlyCommand).
87
+ case .alert:
88
+ return CommandTraits(isInteraction: false, readOnly: .conditional, isLifecycle: false)
89
+
90
+ // Runner-lifecycle commands: skip the app-activation preflight.
91
+ case .recordStop, .uptime, .shutdown:
92
+ return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)
93
+
94
+ // Normal preflight, not retried.
95
+ // NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground
96
+ // guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
97
+ // check first (tracked as a follow-up). Also preserved: querySelector is NOT read-only;
98
+ // recordStart is NOT a lifecycle command; home/alert remain non-interaction by design.
99
+ case .mouseClick, .querySelector, .home, .recordStart:
100
+ return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false)
101
+ }
102
+ }
103
+ }
104
+
37
105
  struct Command: Codable {
38
106
  let command: CommandType
39
107
  let appBundleId: String?
@@ -154,10 +222,12 @@ struct DataPayload: Codable {
154
222
  struct ErrorPayload: Codable {
155
223
  let code: String?
156
224
  let message: String
225
+ let hint: String?
157
226
 
158
- init(code: String? = nil, message: String) {
227
+ init(code: String? = nil, message: String, hint: String? = nil) {
159
228
  self.code = code
160
229
  self.message = message
230
+ self.hint = hint
161
231
  }
162
232
  }
163
233
 
@@ -1,6 +1,9 @@
1
1
  import XCTest
2
2
 
3
3
  extension RunnerTests {
4
+ private static let axSnapshotErrorCode = "IOS_AX_SNAPSHOT_FAILED"
5
+ private static let axSnapshotHint =
6
+ "XCTest could not serialize this iOS accessibility tree. Try a smaller read such as snapshot -s <visible label or id> -d 8, use direct selector commands such as find id <value> click, or use screenshot/logs/appstate in the same session. If you own the app and need full-tree inspection, consider flagging this screen for accessibility-tree simplification: reduce unnecessary accessible wrapper nesting and expose stable ids on actionable controls."
4
7
  private static let collapsedTabCandidateTypes: Set<XCUIElement.ElementType> = [
5
8
  .button,
6
9
  .link,
@@ -33,6 +36,12 @@ extension RunnerTests {
33
36
  let visible: Bool
34
37
  }
35
38
 
39
+ struct SnapshotCaptureFailure: Error {
40
+ let code: String
41
+ let message: String
42
+ let hint: String
43
+ }
44
+
36
45
  // MARK: - Snapshot Entry
37
46
 
38
47
  func elementTypeName(_ type: XCUIElement.ElementType) -> String {
@@ -75,12 +84,12 @@ extension RunnerTests {
75
84
  }
76
85
  }
77
86
 
78
- func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
87
+ func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
79
88
  if let blocking = blockingSystemAlertSnapshot() {
80
89
  return blocking
81
90
  }
82
91
 
83
- guard let context = makeSnapshotTraversalContext(app: app, options: options) else {
92
+ guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
84
93
  return DataPayload(nodes: [], truncated: false)
85
94
  }
86
95
 
@@ -186,12 +195,12 @@ extension RunnerTests {
186
195
  return DataPayload(nodes: nodes, truncated: truncated)
187
196
  }
188
197
 
189
- func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
198
+ func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
190
199
  if let blocking = blockingSystemAlertSnapshot() {
191
200
  return blocking
192
201
  }
193
202
 
194
- guard let context = makeSnapshotTraversalContext(app: app, options: options) else {
203
+ guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
195
204
  return DataPayload(nodes: [], truncated: false)
196
205
  }
197
206
 
@@ -304,14 +313,11 @@ extension RunnerTests {
304
313
  private func makeSnapshotTraversalContext(
305
314
  app: XCUIApplication,
306
315
  options: SnapshotOptions
307
- ) -> SnapshotTraversalContext? {
308
- let viewport = snapshotViewport(app: app)
316
+ ) throws -> SnapshotTraversalContext? {
317
+ let viewport = safeSnapshotViewport(app: app)
309
318
  let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
310
319
 
311
- let rootSnapshot: XCUIElementSnapshot
312
- do {
313
- rootSnapshot = try queryRoot.snapshot()
314
- } catch {
320
+ guard let rootSnapshot = try captureSnapshotRoot(queryRoot) else {
315
321
  return nil
316
322
  }
317
323
 
@@ -326,6 +332,70 @@ extension RunnerTests {
326
332
  )
327
333
  }
328
334
 
335
+ private func captureSnapshotRoot(_ element: XCUIElement) throws -> XCUIElementSnapshot? {
336
+ var rootSnapshot: XCUIElementSnapshot?
337
+ var swiftErrorMessage: String?
338
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
339
+ do {
340
+ rootSnapshot = try element.snapshot()
341
+ } catch {
342
+ swiftErrorMessage = describeSnapshotError(error)
343
+ }
344
+ })
345
+
346
+ if let rootSnapshot {
347
+ return rootSnapshot
348
+ }
349
+ let message = exceptionMessage ?? swiftErrorMessage ?? "snapshot returned no root"
350
+ if Self.isAxIllegalArgument(message) {
351
+ throw axSnapshotFailure(message)
352
+ }
353
+ return nil
354
+ }
355
+
356
+ private func safeSnapshotViewport(app: XCUIApplication) -> CGRect {
357
+ var viewport = CGRect.infinite
358
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
359
+ viewport = snapshotViewport(app: app)
360
+ })
361
+ if let exceptionMessage {
362
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_VIEWPORT_IGNORED_EXCEPTION=%@", exceptionMessage)
363
+ }
364
+ return viewport
365
+ }
366
+
367
+ private func describeSnapshotError(_ error: Error) -> String {
368
+ let localized = error.localizedDescription
369
+ let debug = String(describing: error)
370
+ if localized.isEmpty { return debug }
371
+ if debug == localized { return localized }
372
+ return "\(localized) (\(debug))"
373
+ }
374
+
375
+ private func axSnapshotFailure(_ message: String) -> SnapshotCaptureFailure {
376
+ let failureMessage: String
377
+ if Self.hasAxIllegalArgumentCode(message) {
378
+ failureMessage = "iOS XCTest snapshot failed with kAXErrorIllegalArgument. \(message)"
379
+ } else {
380
+ failureMessage = "iOS XCTest snapshot failed while serializing the accessibility tree. \(message)"
381
+ }
382
+ return SnapshotCaptureFailure(
383
+ code: Self.axSnapshotErrorCode,
384
+ message: failureMessage,
385
+ hint: Self.axSnapshotHint
386
+ )
387
+ }
388
+
389
+ private static func isAxIllegalArgument(_ message: String) -> Bool {
390
+ let normalized = message.lowercased()
391
+ return hasAxIllegalArgumentCode(normalized)
392
+ || (normalized.contains("illegal argument") && normalized.contains("snapshot"))
393
+ }
394
+
395
+ private static func hasAxIllegalArgumentCode(_ message: String) -> Bool {
396
+ return message.lowercased().contains("kaxerrorillegalargument")
397
+ }
398
+
329
399
  private func evaluateSnapshot(
330
400
  _ snapshot: XCUIElementSnapshot,
331
401
  in context: SnapshotTraversalContext
@@ -2,7 +2,9 @@ import XCTest
2
2
 
3
3
  enum RunnerInteractionOutcome {
4
4
  case performed
5
- case unsupported(String)
5
+ /// A capability/state gap, surfaced to the caller as an UNSUPPORTED_OPERATION error.
6
+ /// `hint` is an optional actionable next step (mapped to ErrorPayload.hint).
7
+ case unsupported(message: String, hint: String?)
6
8
  }
7
9
 
8
10
  enum TvRemoteButton {
@@ -85,7 +87,10 @@ extension RunnerTests {
85
87
  func selectFocusedTvElement(app: XCUIApplication, point: CGPoint, action: String) -> RunnerInteractionOutcome? {
86
88
  #if os(tvOS)
87
89
  guard let focused = focusedTvElement(app: app), !focused.frame.isEmpty, focused.frame.contains(point) else {
88
- return .unsupported("\(action) is supported on tvOS only when the requested point is inside the focused element")
90
+ return .unsupported(
91
+ message: "\(action) is supported on tvOS only when the requested point is inside the focused element",
92
+ hint: "Move focus with swipe or scroll until the target is focused, then retry."
93
+ )
89
94
  }
90
95
  _ = pressTvRemote(.select)
91
96
  return .performed
@@ -97,7 +102,10 @@ extension RunnerTests {
97
102
  func longSelectFocusedTvElement(app: XCUIApplication, point: CGPoint, duration: TimeInterval) -> RunnerInteractionOutcome? {
98
103
  #if os(tvOS)
99
104
  guard let focused = focusedTvElement(app: app), !focused.frame.isEmpty, focused.frame.contains(point) else {
100
- return .unsupported("long press is supported on tvOS only when the requested point is inside the focused element")
105
+ return .unsupported(
106
+ message: "long press is supported on tvOS only when the requested point is inside the focused element",
107
+ hint: "Move focus with swipe or scroll until the target is focused, then retry."
108
+ )
101
109
  }
102
110
  _ = pressTvRemote(.select, duration: duration)
103
111
  return .performed
@@ -108,17 +116,37 @@ extension RunnerTests {
108
116
 
109
117
  private func performElementTap(_ element: XCUIElement) -> RunnerInteractionOutcome {
110
118
  #if os(tvOS)
111
- return .unsupported("element tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
119
+ return .unsupported(
120
+ message: "element tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
121
+ hint: "Use swipe/scroll to move focus to the target, then select it; tvOS has no coordinate tap."
122
+ )
112
123
  #else
113
- element.tap()
124
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
125
+ element.tap()
126
+ })
127
+ if let exceptionMessage {
128
+ NSLog("AGENT_DEVICE_RUNNER_ELEMENT_TAP_IGNORED_EXCEPTION=%@", exceptionMessage)
129
+ if isPostTapElementDisappearance(exceptionMessage) {
130
+ return .performed
131
+ }
132
+ return .unsupported(message: "element tap failed: \(exceptionMessage)", hint: nil)
133
+ }
114
134
  return .performed
115
135
  #endif
116
136
  }
117
137
 
138
+ private func isPostTapElementDisappearance(_ message: String) -> Bool {
139
+ message.contains("No matches found")
140
+ || message.contains("Failed to get matching snapshot")
141
+ }
142
+
118
143
  private func selectFocusedTvElement(app: XCUIApplication, element: XCUIElement, action: String) -> RunnerInteractionOutcome? {
119
144
  #if os(tvOS)
120
145
  guard tvFocusedElementMatches(app: app, target: element) else {
121
- return .unsupported("\(action) is supported on tvOS only when the requested element is focused")
146
+ return .unsupported(
147
+ message: "\(action) is supported on tvOS only when the requested element is focused",
148
+ hint: "Move focus to the target element first, then retry."
149
+ )
122
150
  }
123
151
  _ = pressTvRemote(.select)
124
152
  return .performed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.16.6",
3
+ "version": "0.16.8",
4
4
  "description": "Agent-native CLI for AI mobile testing and app automation across iOS, Android, tvOS, Android TV, macOS, and Linux.",
5
5
  "mcpName": "io.github.callstackincubator/agent-device",
6
6
  "license": "MIT",
@@ -98,6 +98,9 @@
98
98
  "ad": "node bin/agent-device.mjs",
99
99
  "size": "node scripts/size-report.mjs",
100
100
  "size:markdown": "node scripts/size-report.mjs --json .tmp/size-report.json --markdown .tmp/size-report.md",
101
+ "perf": "node --experimental-strip-types scripts/perf/run.ts",
102
+ "perf:ios": "node --experimental-strip-types scripts/perf/run.ts --platform ios",
103
+ "perf:android": "node --experimental-strip-types scripts/perf/run.ts --platform android",
101
104
  "lint": "oxlint . --deny-warnings",
102
105
  "format": "oxfmt --write src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
103
106
  "fallow": "fallow --summary",
@@ -160,11 +163,6 @@
160
163
  "README.md",
161
164
  "LICENSE"
162
165
  ],
163
- "pnpm": {
164
- "overrides": {
165
- "lodash-es": "4.18.1"
166
- }
167
- },
168
166
  "keywords": [
169
167
  "agent",
170
168
  "device",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/callstackincubator/agent-device",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.16.6",
10
+ "version": "0.16.8",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "agent-device",
15
- "version": "0.16.6",
15
+ "version": "0.16.8",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -1 +0,0 @@
1
- fc975087b6d0ce63e25cb5d9475f896e828222ec4d7ed12d73e4045cb7cc6c5a agent-device-android-multitouch-helper-0.16.6.apk
@@ -1 +0,0 @@
1
- a6a8555dda194d1e882a930bc6ccf555ff48f1645faf92968a0832f864c91661 agent-device-android-snapshot-helper-0.16.6.apk
package/dist/src/5186.js DELETED
@@ -1 +0,0 @@
1
- let e={devices:"List available devices.",boot:"Boot or prepare a selected device without using CLI positional arguments.",apps:"List installed apps.",session:"List active sessions.",open:"Open an app, deep link, URL, or platform surface.",close:"Close an app or end the active session.",install:"Install an app binary.",reinstall:"Reinstall an app binary.","install-from-source":"Install an app from a structured source.",push:"Deliver a push payload.","trigger-app-event":"Trigger an app-defined event.",snapshot:"Capture an accessibility snapshot.",screenshot:"Capture a screenshot.",diff:"Diff accessibility snapshots.",wait:"Wait for duration, text, ref, or selector.",alert:"Inspect or handle platform alerts.",appstate:"Show foreground app or activity.",back:"Navigate back.",home:"Go to the home screen.",rotate:"Rotate device orientation.","app-switcher":"Open the app switcher.",keyboard:"Inspect or dismiss the keyboard.",clipboard:"Read or write clipboard text.","react-native":"Run supported React Native app automation helpers.",replay:"Replay a recorded session.",test:"Run one or more replay scripts.",perf:"Show session performance metrics.",logs:"Manage session app logs.",network:"Show recent HTTP traffic.",record:"Start or stop screen recording.",trace:"Start or stop trace capture.",settings:"Change OS settings and app permissions.",metro:"Prepare Metro runtime or reload React Native apps.",click:"Click or tap a semantic UI target by ref, selector, or point.",press:"Press a semantic UI target by ref, selector, or point.",fill:"Fill text into a semantic UI target by ref, selector, or point.",longpress:"Long press by ref, selector, or point.",swipe:"Swipe between two points.",focus:"Focus input at coordinates.",type:"Type text in the focused field.",scroll:"Scroll in a direction or to an edge.",get:"Get element text or attributes.",is:"Assert UI state.",find:"Find an element and optionally act on it.",gesture:"Run a structured gesture.",batch:"Run multiple structured command steps in one daemon request."};function t(t){let r=e[t];if(!r)throw Error(`Missing command description for ${t}`);return r}function r(){return Object.entries(e).map(([e,t])=>({name:e,description:t}))}export{r as listCommandDescriptionMetadata,t as requireCommandDescription};