agent-device 0.11.6 → 0.11.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.
@@ -246,7 +246,7 @@ declare type CliFlags = {
246
246
  sessionLock?: 'reject' | 'strip';
247
247
  sessionLocked?: boolean;
248
248
  sessionLockConflicts?: 'reject' | 'strip';
249
- platform?: 'ios' | 'macos' | 'android' | 'apple';
249
+ platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple';
250
250
  target?: 'mobile' | 'tv' | 'desktop';
251
251
  device?: string;
252
252
  udid?: string;
@@ -498,7 +498,7 @@ declare type MetroRuntimeHints = {
498
498
  launchUrl?: string;
499
499
  };
500
500
 
501
- declare type Platform = ApplePlatform | 'android';
501
+ declare type Platform = ApplePlatform | 'android' | 'linux';
502
502
 
503
503
  declare type PlatformSelector = Platform | 'apple';
504
504
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.11.6",
3
+ "version": "0.11.8",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -45,7 +45,8 @@
45
45
  "test:replay:ios": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator",
46
46
  "test:replay:ios-device": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/device",
47
47
  "test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android",
48
- "test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos"
48
+ "test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos",
49
+ "test:replay:linux": "node --experimental-strip-types src/bin.ts test test/integration/replays/linux"
49
50
  },
50
51
  "files": [
51
52
  "bin",
@@ -57,6 +58,7 @@
57
58
  "!ios-runner/**/*.xcuserstate",
58
59
  "macos-helper",
59
60
  "!macos-helper/**/.build",
61
+ "src/platforms/linux/atspi-dump.py",
60
62
  "skills",
61
63
  "README.md",
62
64
  "LICENSE"
@@ -36,6 +36,7 @@ Logging is off by default. Enable it only when you need a debugging window.
36
36
  - Default app logs live under `~/.agent-device/sessions/<session>/app.log`.
37
37
  - `logs clear --restart` is the fastest clean repro loop.
38
38
  - `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) entries from the same session app log.
39
+ - On macOS, `network dump` is app-scoped and only sees Unified Logging associated with the active session app.
39
40
  - On iOS simulators, `network dump` can recover recent app log history with `simctl log show` when the live session stream is sparse, so check the returned notes before assuming the repro window was empty.
40
41
  - On iOS, `network dump` is still limited to what Unified Logging exposes for the app process. If the app does not emit request metadata there, `network dump` can legitimately return no HTTP entries even during a real repro.
41
42
  - Summary output already shows timestamp, status, and duration when the log backend exposes them.
@@ -44,6 +45,7 @@ Logging is off by default. Enable it only when you need a debugging window.
44
45
  - `logs doctor` checks backend and runtime readiness for the current session and device.
45
46
  - `logs mark "before tap"` inserts a timestamped marker into the app log.
46
47
  - Android `network dump` surfaces timestamps from logcat-style prefixes and can backfill status and request/response duration from adjacent GIBSDK packet lines, so check it before dumping raw log windows.
48
+ - Android app-log streaming rebinds to the current app PID after relaunches, so rerun the repro window before assuming the last log slice is stale.
47
49
  - Marker lines are emitted with the `[agent-device][mark][...]` prefix. When you grep later, prefer a narrow pattern such as `grep -n -E "agent-device.*mark|before tap" <path>`.
48
50
  - Session app logs can contain runtime data, headers, or payload fragments. Review them before sharing.
49
51
  - `logs start` requires an active app session and appends to `app.log`.
@@ -78,16 +80,16 @@ If the app showed a visible warning or error overlay during the flow:
78
80
 
79
81
  ## Alerts and permissions
80
82
 
81
- Use `alert` for iOS simulator permission dialogs instead of tapping coordinates.
83
+ Use `alert` for iOS simulator permission dialogs and macOS desktop alerts instead of tapping coordinates.
82
84
 
83
85
  ```bash
84
86
  agent-device alert wait 5000
85
87
  agent-device alert accept
86
88
  ```
87
89
 
88
- - `alert` is only supported on iOS simulators.
90
+ - `alert` is supported on iOS simulators and macOS desktop targets.
89
91
  - `alert accept` and `alert dismiss` retry internally for a short window, so you usually do not need manual sleeps.
90
- - If a permission sheet is visible in `snapshot` or `screenshot` but `alert accept` says no alert was found, treat it as normal tappable UI for that run: take a scoped `snapshot -i -s "<visible label>"` and `press @ref` instead of looping on `alert`.
92
+ - If a permission sheet or modal is visible in `snapshot` or `screenshot` but `alert accept` says no alert was found, treat it as normal tappable UI for that run: take a scoped `snapshot -i -s "<visible label>"` and `press @ref` instead of looping on `alert`.
91
93
  - iOS 16+ "Allow Paste" prompts are suppressed under XCUITest. Use `xcrun simctl pbcopy booted` when you need to seed simulator clipboard content directly.
92
94
 
93
95
  ## Setup problems worth recognizing early
@@ -107,4 +107,5 @@ agent-device perf --json
107
107
  - `startup` is command round-trip timing around `open`.
108
108
  - It is not true first-frame or first-interactive telemetry.
109
109
  - Android app sessions also expose `memory` (`dumpsys meminfo`) and `cpu` (`dumpsys cpuinfo`) snapshots when the session has an app package context.
110
- - iOS still reports `fps`, `memory`, and `cpu` as unavailable placeholders.
110
+ - Apple app sessions on macOS and iOS simulators also expose `memory` and `cpu` process snapshots when the session has an app bundle ID.
111
+ - `fps` is still unavailable, and physical iOS devices still leave `memory` and `cpu` unavailable in this release.
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ AT-SPI2 accessibility tree dumper.
4
+
5
+ Traverses the AT-SPI2 accessibility tree and outputs JSON to stdout.
6
+ Used by agent-device's Linux platform support as a subprocess.
7
+
8
+ Requires: python3-gi, gir1.2-atspi-2.0
9
+ """
10
+
11
+ import json
12
+ import sys
13
+
14
+ import gi
15
+ gi.require_version("Atspi", "2.0")
16
+ from gi.repository import Atspi # noqa: E402
17
+
18
+ MAX_NODES = 1500
19
+ MAX_DEPTH = 12
20
+ MAX_DESKTOP_APPS = 24
21
+
22
+ VALID_SURFACES = ("desktop", "frontmost-app")
23
+
24
+
25
+ def get_rect(accessible):
26
+ try:
27
+ component = accessible.get_component_iface()
28
+ if not component:
29
+ return None
30
+ extents = component.get_extents(Atspi.CoordType.SCREEN)
31
+ if not extents:
32
+ return None
33
+ if extents.width <= 0 or extents.height <= 0:
34
+ return None
35
+ return {
36
+ "x": extents.x,
37
+ "y": extents.y,
38
+ "width": extents.width,
39
+ "height": extents.height,
40
+ }
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ def get_text_value(accessible):
46
+ try:
47
+ text_iface = accessible.get_text_iface()
48
+ if not text_iface:
49
+ return None
50
+ count = text_iface.get_character_count()
51
+ if count <= 0:
52
+ return None
53
+ value = text_iface.get_text(0, count)
54
+ return value if value else None
55
+ except Exception:
56
+ return None
57
+
58
+
59
+ def get_numeric_value(accessible):
60
+ try:
61
+ value_iface = accessible.get_value_iface()
62
+ if not value_iface:
63
+ return None
64
+ current = value_iface.get_current_value()
65
+ if current is None:
66
+ return None
67
+ return str(current)
68
+ except Exception:
69
+ return None
70
+
71
+
72
+ def has_state(state_set, state_type):
73
+ try:
74
+ return state_set.contains(state_type)
75
+ except Exception:
76
+ return False
77
+
78
+
79
+ def traverse_node(accessible, depth, parent_index, ctx, app_info, window_title=None):
80
+ if len(ctx["nodes"]) >= ctx["max_nodes"] or depth > ctx["max_depth"] or not accessible:
81
+ return
82
+
83
+ try:
84
+ role_name = accessible.get_role_name() or "unknown"
85
+ except Exception:
86
+ role_name = "unknown"
87
+
88
+ try:
89
+ name = accessible.get_name() or ""
90
+ except Exception:
91
+ name = ""
92
+
93
+ try:
94
+ description = accessible.get_description() or ""
95
+ except Exception:
96
+ description = ""
97
+
98
+ label = name or description or None
99
+ rect = get_rect(accessible)
100
+
101
+ try:
102
+ state_set = accessible.get_state_set()
103
+ except Exception:
104
+ state_set = None
105
+
106
+ enabled = has_state(state_set, Atspi.StateType.ENABLED) if state_set else None
107
+ selected = has_state(state_set, Atspi.StateType.SELECTED) if state_set else None
108
+ visible = has_state(state_set, Atspi.StateType.VISIBLE) if state_set else True
109
+ showing = has_state(state_set, Atspi.StateType.SHOWING) if state_set else True
110
+ hittable = (enabled is not False) and visible and showing and (rect is not None)
111
+
112
+ current_window_title = window_title
113
+ if current_window_title is None and role_name in ("frame", "window", "dialog"):
114
+ current_window_title = label
115
+
116
+ nodes = ctx["nodes"]
117
+ node_index = len(nodes)
118
+ value = get_text_value(accessible) or get_numeric_value(accessible)
119
+
120
+ node = {
121
+ "index": node_index,
122
+ "role": role_name,
123
+ "label": label,
124
+ "value": value,
125
+ "rect": rect,
126
+ "enabled": enabled,
127
+ "selected": selected,
128
+ "hittable": hittable,
129
+ "depth": depth,
130
+ "parentIndex": parent_index,
131
+ "pid": app_info.get("pid"),
132
+ "appName": app_info.get("appName"),
133
+ "windowTitle": current_window_title,
134
+ }
135
+ nodes.append(node)
136
+
137
+ try:
138
+ child_count = accessible.get_child_count()
139
+ except Exception:
140
+ return
141
+
142
+ for i in range(child_count):
143
+ if len(nodes) >= ctx["max_nodes"]:
144
+ break
145
+ try:
146
+ child = accessible.get_child_at_index(i)
147
+ if child:
148
+ traverse_node(
149
+ child, depth + 1, node_index, ctx, app_info,
150
+ current_window_title
151
+ )
152
+ except Exception:
153
+ pass
154
+
155
+
156
+ def find_focused_application(desktop, app_count):
157
+ for i in range(app_count):
158
+ try:
159
+ app = desktop.get_child_at_index(i)
160
+ if not app:
161
+ continue
162
+ child_count = app.get_child_count()
163
+ for j in range(child_count):
164
+ try:
165
+ win = app.get_child_at_index(j)
166
+ if not win:
167
+ continue
168
+ state_set = win.get_state_set()
169
+ if state_set and has_state(state_set, Atspi.StateType.ACTIVE):
170
+ return app
171
+ except Exception:
172
+ pass
173
+ except Exception:
174
+ pass
175
+
176
+ # Fallback: first app with children
177
+ for i in range(app_count):
178
+ try:
179
+ app = desktop.get_child_at_index(i)
180
+ if app and app.get_child_count() > 0:
181
+ return app
182
+ except Exception:
183
+ pass
184
+ return None
185
+
186
+
187
+ def get_app_info(app):
188
+ try:
189
+ app_name = app.get_name() or None
190
+ except Exception:
191
+ app_name = None
192
+ try:
193
+ pid = app.get_process_id()
194
+ except Exception:
195
+ pid = None
196
+ return {"appName": app_name, "pid": pid}
197
+
198
+
199
+ def capture(surface, max_nodes=MAX_NODES, max_depth=MAX_DEPTH, max_apps=MAX_DESKTOP_APPS):
200
+ desktop = Atspi.get_desktop(0)
201
+ if not desktop:
202
+ return {"error": "Could not get desktop accessible. Is the accessibility bus running?"}
203
+
204
+ app_count = desktop.get_child_count()
205
+ ctx = {"nodes": [], "max_nodes": max_nodes, "max_depth": max_depth}
206
+
207
+ if surface == "frontmost-app":
208
+ focused = find_focused_application(desktop, app_count)
209
+ if focused:
210
+ traverse_node(focused, 0, None, ctx, get_app_info(focused))
211
+ else:
212
+ apps_to_traverse = min(app_count, max_apps)
213
+ for i in range(apps_to_traverse):
214
+ if len(ctx["nodes"]) >= max_nodes:
215
+ break
216
+ try:
217
+ app = desktop.get_child_at_index(i)
218
+ if not app or app.get_child_count() == 0:
219
+ continue
220
+ traverse_node(app, 0, None, ctx, get_app_info(app))
221
+ except Exception:
222
+ pass
223
+
224
+ nodes = ctx["nodes"]
225
+ return {
226
+ "nodes": nodes,
227
+ "truncated": len(nodes) >= max_nodes,
228
+ "surface": surface,
229
+ }
230
+
231
+
232
+ def parse_int_arg(value, name):
233
+ try:
234
+ n = int(value)
235
+ if n < 0:
236
+ raise ValueError(f"negative value")
237
+ return n
238
+ except ValueError as e:
239
+ json.dump({"error": f"Invalid value for {name}: '{value}' ({e})"}, sys.stdout)
240
+ sys.exit(1)
241
+
242
+
243
+ def main():
244
+ try:
245
+ surface = "desktop"
246
+ max_nodes = MAX_NODES
247
+ max_depth = MAX_DEPTH
248
+ max_apps = MAX_DESKTOP_APPS
249
+
250
+ args = sys.argv[1:]
251
+ i = 0
252
+ while i < len(args):
253
+ if args[i] == "--surface" and i + 1 < len(args):
254
+ surface = args[i + 1]
255
+ i += 2
256
+ elif args[i] == "--max-nodes" and i + 1 < len(args):
257
+ max_nodes = parse_int_arg(args[i + 1], "--max-nodes")
258
+ i += 2
259
+ elif args[i] == "--max-depth" and i + 1 < len(args):
260
+ max_depth = parse_int_arg(args[i + 1], "--max-depth")
261
+ i += 2
262
+ elif args[i] == "--max-apps" and i + 1 < len(args):
263
+ max_apps = parse_int_arg(args[i + 1], "--max-apps")
264
+ i += 2
265
+ else:
266
+ i += 1
267
+
268
+ if surface not in VALID_SURFACES:
269
+ json.dump(
270
+ {"error": f"Unknown surface '{surface}'. Valid: {', '.join(VALID_SURFACES)}"},
271
+ sys.stdout,
272
+ )
273
+ sys.exit(1)
274
+
275
+ result = capture(surface, max_nodes, max_depth, max_apps)
276
+ json.dump(result, sys.stdout, ensure_ascii=False)
277
+ except SystemExit:
278
+ raise
279
+ except Exception as e:
280
+ json.dump({"error": f"Unexpected error: {e}"}, sys.stdout)
281
+ sys.exit(1)
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()