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.
- package/README.md +2 -1
- package/dist/src/bin.js +1 -1
- package/dist/src/daemon.js +45 -45
- package/dist/src/index.d.ts +2 -2
- package/package.json +4 -2
- package/skills/agent-device/references/debugging.md +5 -3
- package/skills/agent-device/references/verification.md +2 -1
- package/src/platforms/linux/atspi-dump.py +285 -0
package/dist/src/index.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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()
|