agent-device 0.15.0 → 0.15.1

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 (26) hide show
  1. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.15.0.apk → agent-device-android-snapshot-helper-0.15.1.apk} +0 -0
  2. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.1.apk.sha256 +1 -0
  3. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.15.0.manifest.json → agent-device-android-snapshot-helper-0.15.1.manifest.json} +6 -6
  4. package/dist/src/1393.js +1 -0
  5. package/dist/src/1769.js +7 -7
  6. package/dist/src/1974.js +2 -2
  7. package/dist/src/208.js +1 -1
  8. package/dist/src/2151.js +27 -22
  9. package/dist/src/221.js +3 -3
  10. package/dist/src/3572.js +1 -1
  11. package/dist/src/4829.js +1 -1
  12. package/dist/src/9542.js +2 -2
  13. package/dist/src/989.js +1 -1
  14. package/dist/src/android-adb.js +1 -1
  15. package/dist/src/cli.js +44 -44
  16. package/dist/src/index.d.ts +37 -5
  17. package/dist/src/internal/daemon.js +45 -45
  18. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +155 -0
  19. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +3 -25
  20. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +1 -14
  21. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +3 -3
  22. package/ios-runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +7 -1
  23. package/package.json +6 -1
  24. package/server.json +2 -2
  25. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.0.apk.sha256 +0 -1
  26. package/dist/src/840.js +0 -2
@@ -0,0 +1,155 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ struct RunnerAlert {
5
+ let root: XCUIElement
6
+ let ownerApp: XCUIApplication
7
+ let buttons: [XCUIElement]
8
+ }
9
+
10
+ func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
11
+ if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
12
+ return runnerAlert(root: alert, ownerApp: activeApp)
13
+ }
14
+ if let popup = firstDismissPopupWindow(in: activeApp) {
15
+ return runnerAlert(root: popup, ownerApp: activeApp)
16
+ }
17
+ #if os(macOS)
18
+ return nil
19
+ #else
20
+ if let systemModal = firstBlockingSystemModal(in: springboard) {
21
+ return runnerAlert(root: systemModal, ownerApp: springboard)
22
+ }
23
+ return nil
24
+ #endif
25
+ }
26
+
27
+ func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
28
+ if action == "accept" || action == "dismiss" {
29
+ guard let button = chooseAlertButton(alert.buttons, action: action) else {
30
+ return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found"))
31
+ }
32
+ let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
33
+ if let response = unsupportedResponse(for: outcome) {
34
+ return response
35
+ }
36
+ return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
37
+ }
38
+
39
+ return Response(
40
+ ok: true,
41
+ data: DataPayload(
42
+ message: preferredAlertTitle(alert.root, buttons: alert.buttons),
43
+ items: alert.buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines) }
44
+ )
45
+ )
46
+ }
47
+
48
+ private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? {
49
+ let buttons = actionableElements(in: root).filter { isEnabledElement($0) }
50
+ guard !buttons.isEmpty else {
51
+ return nil
52
+ }
53
+ return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
54
+ }
55
+
56
+ private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
57
+ elements.first { isVisibleElement($0) }
58
+ }
59
+
60
+ private func firstDismissPopupWindow(in app: XCUIApplication) -> XCUIElement? {
61
+ safeElementsQuery {
62
+ app.windows.allElementsBoundByIndex
63
+ }.first { window in
64
+ if !isVisibleElement(window) { return false }
65
+ if isDismissPopupMarker(window.label) || isDismissPopupMarker(window.identifier) {
66
+ return true
67
+ }
68
+ return safeElementsQuery {
69
+ window.descendants(matching: .any).allElementsBoundByIndex
70
+ }.contains { descendant in
71
+ isDismissPopupMarker(descendant.label) || isDismissPopupMarker(descendant.identifier)
72
+ }
73
+ }
74
+ }
75
+
76
+ private func chooseAlertButton(_ buttons: [XCUIElement], action: String) -> XCUIElement? {
77
+ if action == "accept" {
78
+ if let accept = buttons.first(where: { isAcceptButton($0.label) }) {
79
+ return accept
80
+ }
81
+ return buttons.count == 1 && !isDismissButton(buttons[0].label) ? buttons[0] : nil
82
+ }
83
+
84
+ return buttons.first(where: { isDismissButton($0.label) }) ?? buttons.last
85
+ }
86
+
87
+ private func isAcceptButton(_ label: String) -> Bool {
88
+ let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
89
+ return [
90
+ "ok",
91
+ "allow",
92
+ "yes",
93
+ "continue",
94
+ "done",
95
+ "open settings"
96
+ ].contains(normalized) || normalized.hasPrefix("confirm")
97
+ }
98
+
99
+ private func isDismissButton(_ label: String) -> Bool {
100
+ [
101
+ "cancel",
102
+ "close",
103
+ "dismiss",
104
+ "don't allow",
105
+ "don’t allow",
106
+ "not now",
107
+ "no",
108
+ "keep browsing",
109
+ "later"
110
+ ].contains(label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
111
+ }
112
+
113
+ private func preferredAlertTitle(_ element: XCUIElement, buttons: [XCUIElement]) -> String {
114
+ let buttonLabels = Set(buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() })
115
+ let descendants = element.descendants(matching: .any).allElementsBoundByIndex
116
+ for descendant in descendants {
117
+ let text = descendant.label.trimmingCharacters(in: .whitespacesAndNewlines)
118
+ if text.isEmpty ||
119
+ isGenericAlertLabel(text) ||
120
+ buttonLabels.contains(text.lowercased()) ||
121
+ descendant.elementType == .navigationBar ||
122
+ actionableTypes.contains(descendant.elementType)
123
+ {
124
+ continue
125
+ }
126
+ return text
127
+ }
128
+ let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
129
+ return label.isEmpty || isGenericAlertLabel(label) ? "Alert" : label
130
+ }
131
+
132
+ private func isGenericAlertLabel(_ label: String) -> Bool {
133
+ let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
134
+ return isDismissPopupMarker(normalized) ||
135
+ normalized.hasPrefix("vertical scroll bar") ||
136
+ normalized.hasPrefix("horizontal scroll bar") ||
137
+ normalized == "tab bar"
138
+ }
139
+
140
+ private func isVisibleElement(_ element: XCUIElement) -> Bool {
141
+ element.exists && !element.frame.isNull && !element.frame.isEmpty
142
+ }
143
+
144
+ private func isEnabledElement(_ element: XCUIElement) -> Bool {
145
+ var enabled = false
146
+ _ = RunnerObjCExceptionCatcher.catchException({
147
+ enabled = element.exists && element.isEnabled
148
+ })
149
+ return enabled
150
+ }
151
+
152
+ private func isDismissPopupMarker(_ label: String) -> Bool {
153
+ label.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame
154
+ }
155
+ }
@@ -13,7 +13,7 @@ extension RunnerTests {
13
13
  return (gestureStartUptimeMs, currentUptimeMs())
14
14
  }
15
15
 
16
- private func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
16
+ func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
17
17
  switch outcome {
18
18
  case .performed:
19
19
  return nil
@@ -731,32 +731,10 @@ extension RunnerTests {
731
731
  )
732
732
  case .alert:
733
733
  let action = (command.action ?? "get").lowercased()
734
- let alert = activeApp.alerts.firstMatch
735
- if !alert.exists {
734
+ guard let alert = resolveAlert(app: activeApp) else {
736
735
  return Response(ok: false, error: ErrorPayload(message: "alert not found"))
737
736
  }
738
- if action == "accept" {
739
- guard let button = alert.buttons.allElementsBoundByIndex.first else {
740
- return Response(ok: false, error: ErrorPayload(message: "alert accept button not found"))
741
- }
742
- let outcome = activateElement(app: activeApp, element: button, action: "alert accept")
743
- if let response = unsupportedResponse(for: outcome) {
744
- return response
745
- }
746
- return Response(ok: true, data: DataPayload(message: "accepted"))
747
- }
748
- if action == "dismiss" {
749
- guard let button = alert.buttons.allElementsBoundByIndex.last else {
750
- return Response(ok: false, error: ErrorPayload(message: "alert dismiss button not found"))
751
- }
752
- let outcome = activateElement(app: activeApp, element: button, action: "alert dismiss")
753
- if let response = unsupportedResponse(for: outcome) {
754
- return response
755
- }
756
- return Response(ok: true, data: DataPayload(message: "dismissed"))
757
- }
758
- let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
759
- return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
737
+ return handleAlert(alert, action: action)
760
738
  case .pinch:
761
739
  guard let scale = command.scale, scale > 0 else {
762
740
  return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
@@ -147,7 +147,7 @@ extension RunnerTests {
147
147
  ? (target.value(forKey: "waitForIdleTimeout") as? NSNumber)
148
148
  : nil
149
149
  if supportsWaitForIdleTimeout {
150
- target.setValue(resolveScrollInteractionIdleTimeout(), forKey: "waitForIdleTimeout")
150
+ target.setValue(scrollInteractionIdleTimeoutDefault, forKey: "waitForIdleTimeout")
151
151
  }
152
152
  defer {
153
153
  if let previous {
@@ -191,19 +191,6 @@ extension RunnerTests {
191
191
  }
192
192
  }
193
193
 
194
- private func resolveScrollInteractionIdleTimeout() -> TimeInterval {
195
- guard
196
- let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_IOS_INTERACTION_IDLE_TIMEOUT"],
197
- !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
198
- else {
199
- return scrollInteractionIdleTimeoutDefault
200
- }
201
- guard let parsed = Double(raw), parsed >= 0 else {
202
- return scrollInteractionIdleTimeoutDefault
203
- }
204
- return min(parsed, 30)
205
- }
206
-
207
194
  func shouldRetryCommand(_ command: Command) -> Bool {
208
195
  if RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
209
196
  return false
@@ -46,7 +46,7 @@ extension RunnerTests {
46
46
  #endif
47
47
  }
48
48
 
49
- private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
49
+ func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
50
50
  let disableSafeProbe = RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
51
51
  let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
52
52
  if disableSafeProbe {
@@ -76,7 +76,7 @@ extension RunnerTests {
76
76
  return nil
77
77
  }
78
78
 
79
- private func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
79
+ func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
80
80
  var elements: [XCUIElement] = []
81
81
  let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
82
82
  elements = fetch()
@@ -120,7 +120,7 @@ extension RunnerTests {
120
120
  return true
121
121
  }
122
122
 
123
- private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
123
+ func actionableElements(in element: XCUIElement) -> [XCUIElement] {
124
124
  var seen = Set<String>()
125
125
  var actions: [XCUIElement] = []
126
126
  let descendants = safeElementsQuery {
@@ -145,7 +145,13 @@ func run() throws {
145
145
  in: parentLayer
146
146
  )
147
147
 
148
- guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
148
+ // Overlay burn-in forces a full re-encode; medium quality keeps simulator videos readable
149
+ // while avoiding very slow highest-quality exports.
150
+ let presetName = AVAssetExportSession.exportPresets(compatibleWith: composition)
151
+ .contains(AVAssetExportPresetMediumQuality)
152
+ ? AVAssetExportPresetMediumQuality
153
+ : AVAssetExportPresetHighestQuality
154
+ guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
149
155
  throw OverlayError.exportFailed("Failed to create export session.")
150
156
  }
151
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
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",
@@ -111,6 +111,11 @@
111
111
  "test-app:ios": "pnpm --dir examples/test-app ios",
112
112
  "test-app:android": "pnpm --dir examples/test-app android",
113
113
  "test-app:typecheck": "pnpm --dir examples/test-app typecheck",
114
+ "test-app:replay:ios": "pnpm ad test examples/test-app/replays --platform ios --artifacts-dir .tmp/test-app-replay/ios",
115
+ "test-app:replay:android": "pnpm ad test examples/test-app/replays --platform android --artifacts-dir .tmp/test-app-replay/android",
116
+ "test-app:maestro": "node scripts/run-test-app-maestro-suite.mjs",
117
+ "test-app:maestro:ios": "node scripts/run-test-app-maestro-suite.mjs --platform ios",
118
+ "test-app:maestro:android": "node scripts/run-test-app-maestro-suite.mjs --platform android",
114
119
  "test": "vitest run --project unit",
115
120
  "test:unit": "vitest run --project unit",
116
121
  "test:coverage": "vitest run --coverage",
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.15.0",
10
+ "version": "0.15.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "agent-device",
15
- "version": "0.15.0",
15
+ "version": "0.15.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -1 +0,0 @@
1
- 5fdbdcd5f57e6c152a6a47967748903c0bd2e6655edf19ff0757d815e1975762 agent-device-android-snapshot-helper-0.15.0.apk
package/dist/src/840.js DELETED
@@ -1,2 +0,0 @@
1
- import{AppError as e}from"./9152.js";import{emitDiagnostic as t}from"./7599.js";let s="user-installed";function a(e){return e??s}function n(t){if(void 0===t)throw new e("INVALID_ARGS","appsFilter must be resolved before executing the apps command");return t}let r=i(process.env.AGENT_DEVICE_RETRY_LOGS);function i(e){return["1","true","yes","on"].includes((e??"").trim().toLowerCase())}let d={ios_boot:{startupMs:12e4,operationMs:2e4,totalMs:12e4},ios_runner_connect:{startupMs:12e4,operationMs:15e3,totalMs:12e4},android_boot:{startupMs:6e4,operationMs:1e4,totalMs:6e4}};class l{startedAtMs;expiresAtMs;constructor(e,t){this.startedAtMs=e,this.expiresAtMs=e+Math.max(0,t)}static fromTimeoutMs(e,t=Date.now()){return new l(t,e)}remainingMs(e=Date.now()){return Math.max(0,this.expiresAtMs-e)}elapsedMs(e=Date.now()){return Math.max(0,e-this.startedAtMs)}isExpired(e=Date.now()){return 0>=this.remainingMs(e)}}async function o(t,s={},a={}){let n,r={maxAttempts:s.maxAttempts??3,baseDelayMs:s.baseDelayMs??200,maxDelayMs:s.maxDelayMs??2e3,jitter:s.jitter??.2,shouldRetry:s.shouldRetry};for(let s=1;s<=r.maxAttempts;s+=1){if(a.signal?.aborted)throw new e("COMMAND_FAILED","request canceled",{reason:"request_canceled"});if(a.deadline?.isExpired()&&s>1)break;try{let e=await t({attempt:s,maxAttempts:r.maxAttempts,deadline:a.deadline});return a.onEvent?.({phase:a.phase,event:"succeeded",attempt:s,maxAttempts:r.maxAttempts,elapsedMs:a.deadline?.elapsedMs(),remainingMs:a.deadline?.remainingMs()}),p({phase:a.phase,event:"succeeded",attempt:s,maxAttempts:r.maxAttempts,elapsedMs:a.deadline?.elapsedMs(),remainingMs:a.deadline?.remainingMs()}),e}catch(o){n=o;let e=a.classifyReason?.(o),t={phase:a.phase,event:"attempt_failed",attempt:s,maxAttempts:r.maxAttempts,elapsedMs:a.deadline?.elapsedMs(),remainingMs:a.deadline?.remainingMs(),reason:e};if(a.onEvent?.(t),p(t),s>=r.maxAttempts||r.shouldRetry&&!r.shouldRetry(o,s))break;let i=function(e,t,s,a){let n=Math.min(t,e*2**(a-1));return Math.max(0,n+n*s*(2*Math.random()-1))}(r.baseDelayMs,r.maxDelayMs,r.jitter,s),d=a.deadline?Math.min(i,a.deadline.remainingMs()):i;if(d<=0)break;let l={phase:a.phase,event:"retry_scheduled",attempt:s,maxAttempts:r.maxAttempts,delayMs:d,elapsedMs:a.deadline?.elapsedMs(),remainingMs:a.deadline?.remainingMs(),reason:e};a.onEvent?.(l),p(l),await function(e,t){return new Promise(s=>{if(t?.aborted)return void s();let a=!1,n=()=>{a||(a=!0,t&&t.removeEventListener("abort",i),s())},r=setTimeout(n,e);function i(){clearTimeout(r),n()}t&&t.addEventListener("abort",i,{once:!0})})}(d,a.signal)}}let i={phase:a.phase,event:"exhausted",attempt:r.maxAttempts,maxAttempts:r.maxAttempts,elapsedMs:a.deadline?.elapsedMs(),remainingMs:a.deadline?.remainingMs(),reason:a.classifyReason?.(n)};if(a.onEvent?.(i),p(i),n)throw n;throw new e("COMMAND_FAILED","retry failed")}async function m(e,t={}){return o(()=>e(),{maxAttempts:t.attempts,baseDelayMs:t.baseDelayMs,maxDelayMs:t.maxDelayMs,jitter:t.jitter,shouldRetry:t.shouldRetry})}function p(e){t({level:"attempt_failed"===e.event||"exhausted"===e.event?"warn":"debug",phase:"retry",data:{...e}}),r&&process.stderr.write(`[agent-device][retry] ${JSON.stringify(e)}
2
- `)}export{s as DEFAULT_APPS_FILTER,l as Deadline,d as TIMEOUT_PROFILES,n as assertResolvedAppsFilter,i as isEnvTruthy,a as resolveAppsFilter,o as retryWithPolicy,m as withRetry};