@houwert/conductor 0.10.0 → 0.12.0

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 CHANGED
@@ -71,7 +71,7 @@ Claude learns every available command, how to coordinate across devices, and how
71
71
  |---|---|
72
72
  | App lifecycle | `launch-app`, `stop-app`, `clear-state`, `uninstall-app`, `install-app`, `foreground-app`, `copy-app` |
73
73
  | Interaction | `tap-on`, `input-text`, `scroll`, `scroll-until-visible`, `swipe`, `press-key`, `erase-text`, `hide-keyboard` |
74
- | Inspection | `inspect`, `focused`, `take-screenshot`, `list-apps` |
74
+ | Inspection | `inspect`, `focused`, `take-screenshot`, `capture-ui`, `list-apps` |
75
75
  | Assertions | `assert-visible`, `assert-not-visible` |
76
76
  | Navigation | `open-link`, `back` |
77
77
  | Flows | `run-flow`, `run-flow-inline`, `run-parallel` |
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HELP = void 0;
7
+ exports.captureUI = captureUI;
8
+ exports.HELP = ` capture-ui [--output <path>] Capture screenshot + hierarchy + a11y snapshot (for Argus UI panel)`;
9
+ const promises_1 = __importDefault(require("fs/promises"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const runner_js_1 = require("../runner.js");
12
+ const output_js_1 = require("../output.js");
13
+ const ios_js_1 = require("../drivers/ios.js");
14
+ const android_js_1 = require("../drivers/android.js");
15
+ const web_js_1 = require("../drivers/web.js");
16
+ const a11y_js_1 = require("../drivers/a11y.js");
17
+ async function captureUI(outputPath, opts = {}, sessionName = 'default') {
18
+ try {
19
+ const driver = await (0, runner_js_1.getDriver)(sessionName);
20
+ const capturedAt = new Date().toISOString();
21
+ let platform;
22
+ let width = 0;
23
+ let height = 0;
24
+ let hierarchy;
25
+ let a11ySnapshot;
26
+ let screenshotBuf;
27
+ if (driver instanceof ios_js_1.IOSDriver) {
28
+ platform = driver.platform; // 'ios' | 'tvos'
29
+ const [info, vh, shot] = await Promise.all([
30
+ driver.deviceInfo(),
31
+ driver.viewHierarchy(false),
32
+ driver.screenshot(),
33
+ ]);
34
+ width = info.widthPoints;
35
+ height = info.heightPoints;
36
+ const built = (0, a11y_js_1.buildIOSA11y)(vh.axElement);
37
+ hierarchy = { axElement: built.hierarchy, depth: vh.depth };
38
+ a11ySnapshot = built.a11ySnapshot;
39
+ screenshotBuf = shot;
40
+ }
41
+ else if (driver instanceof web_js_1.WebDriver) {
42
+ platform = 'web';
43
+ const [info, vh, shot] = await Promise.all([
44
+ driver.deviceInfo(),
45
+ driver.viewHierarchy(),
46
+ driver.screenshot(),
47
+ ]);
48
+ width = info.widthPixels;
49
+ height = info.heightPixels;
50
+ const built = (0, a11y_js_1.buildWebA11y)(vh);
51
+ hierarchy = { ...vh, elements: built.hierarchy };
52
+ a11ySnapshot = built.a11ySnapshot;
53
+ screenshotBuf = shot;
54
+ }
55
+ else if (driver instanceof android_js_1.AndroidDriver) {
56
+ platform = 'android';
57
+ const [info, xml, shot] = await Promise.all([
58
+ driver.deviceInfo(),
59
+ driver.viewHierarchy(),
60
+ driver.screenshot(),
61
+ ]);
62
+ width = info.widthPixels;
63
+ height = info.heightPixels;
64
+ const built = (0, a11y_js_1.buildAndroidA11y)(xml);
65
+ hierarchy = { xml, elements: built.hierarchy };
66
+ a11ySnapshot = built.a11ySnapshot;
67
+ screenshotBuf = shot;
68
+ }
69
+ else {
70
+ throw new Error('Unknown driver type');
71
+ }
72
+ const bundle = {
73
+ version: 1,
74
+ capturedAt,
75
+ device: {
76
+ platform,
77
+ deviceId: sessionName,
78
+ width,
79
+ height,
80
+ },
81
+ screenshot: {
82
+ kind: 'composite',
83
+ encoding: 'png',
84
+ data: screenshotBuf.toString('base64'),
85
+ },
86
+ hierarchy,
87
+ a11ySnapshot,
88
+ capabilities: { perViewPixels: false, depthData: false },
89
+ };
90
+ const json = JSON.stringify(bundle);
91
+ if (outputPath) {
92
+ const resolved = path_1.default.resolve(outputPath);
93
+ await promises_1.default.writeFile(resolved, json);
94
+ if (opts.json) {
95
+ console.log(JSON.stringify({
96
+ status: 'ok',
97
+ path: resolved,
98
+ bytes: Buffer.byteLength(json, 'utf-8'),
99
+ }));
100
+ }
101
+ else {
102
+ (0, output_js_1.printSuccess)(`capture-ui saved to ${resolved}`, opts);
103
+ }
104
+ }
105
+ else {
106
+ // Stdout: raw JSON bundle (no pretty-printing — screenshot is huge).
107
+ process.stdout.write(json);
108
+ if (!opts.json)
109
+ process.stdout.write('\n');
110
+ }
111
+ return 0;
112
+ }
113
+ catch (err) {
114
+ const msg = err instanceof Error ? err.message : String(err);
115
+ (0, output_js_1.printError)(`capture-ui — failed\n${msg}`, opts);
116
+ return 1;
117
+ }
118
+ }
@@ -67,7 +67,8 @@ ASSERTIONS
67
67
 
68
68
  SCREENSHOTS & INSPECTION
69
69
  take-screenshot [--output <path>] Take screenshot (default: ./screenshot-<ts>.png)
70
- inspect Print UI hierarchy
70
+ inspect [--dump] Print UI hierarchy (--dump: a11y-enriched JSON)
71
+ capture-ui [--output <path>] Screenshot + hierarchy + a11y snapshot bundle (Argus)
71
72
 
72
73
  FLOW EXECUTION
73
74
  run-flow <file> [--device <id>] Run a Maestro YAML flow file
@@ -9,6 +9,7 @@ const ios_js_1 = require("../drivers/ios.js");
9
9
  const android_js_1 = require("../drivers/android.js");
10
10
  const web_js_1 = require("../drivers/web.js");
11
11
  const element_resolver_js_1 = require("../drivers/element-resolver.js");
12
+ const a11y_js_1 = require("../drivers/a11y.js");
12
13
  async function inspect(opts = {}, sessionName = 'default', inspectOpts = {}) {
13
14
  try {
14
15
  const driver = await (0, runner_js_1.getDriver)(sessionName);
@@ -16,13 +17,20 @@ async function inspect(opts = {}, sessionName = 'default', inspectOpts = {}) {
16
17
  let raw;
17
18
  if (driver instanceof ios_js_1.IOSDriver) {
18
19
  const hierarchy = await driver.viewHierarchy(false);
19
- raw = JSON.stringify(hierarchy, null, 2);
20
+ // Augment each node with a11y fields (traits, accessibilityOrder,
21
+ // isAccessibilityElement, announcement). All existing fields are preserved.
22
+ const built = (0, a11y_js_1.buildIOSA11y)(hierarchy.axElement);
23
+ raw = JSON.stringify({ axElement: built.hierarchy, depth: hierarchy.depth }, null, 2);
20
24
  }
21
25
  else if (driver instanceof web_js_1.WebDriver) {
22
- raw = JSON.stringify(await driver.viewHierarchy(), null, 2);
26
+ const vh = await driver.viewHierarchy();
27
+ const built = (0, a11y_js_1.buildWebA11y)(vh);
28
+ raw = JSON.stringify({ ...vh, elements: built.hierarchy }, null, 2);
23
29
  }
24
30
  else if (driver instanceof android_js_1.AndroidDriver) {
25
- raw = await driver.viewHierarchy();
31
+ const xml = await driver.viewHierarchy();
32
+ const built = (0, a11y_js_1.buildAndroidA11y)(xml);
33
+ raw = JSON.stringify(built.hierarchy, null, 2);
26
34
  }
27
35
  else {
28
36
  throw new Error('Unknown driver type');
@@ -0,0 +1,487 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.memory = memory;
5
+ exports.HELP = ` memory [<appId>] Show device + app memory usage and object counts`;
6
+ const runner_js_1 = require("../runner.js");
7
+ const session_js_1 = require("../session.js");
8
+ const output_js_1 = require("../output.js");
9
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
10
+ const ios_js_1 = require("../drivers/ios.js");
11
+ const android_js_1 = require("../drivers/android.js");
12
+ const web_js_1 = require("../drivers/web.js");
13
+ async function resolveDeviceId(sessionName) {
14
+ if (sessionName !== 'default')
15
+ return sessionName;
16
+ const session = await (0, session_js_1.getSession)(sessionName);
17
+ return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
18
+ }
19
+ async function resolveAppId(explicit, sessionName) {
20
+ if (explicit)
21
+ return explicit;
22
+ const session = await (0, session_js_1.getSession)(sessionName);
23
+ if (session.appId)
24
+ return session.appId;
25
+ // Fall back to whatever's in the foreground.
26
+ try {
27
+ const driver = await (0, runner_js_1.getDriver)(sessionName);
28
+ if (driver instanceof android_js_1.AndroidDriver)
29
+ return await driver.getForegroundApp();
30
+ if (driver instanceof web_js_1.WebDriver)
31
+ return await driver.runningApp();
32
+ if (driver instanceof ios_js_1.IOSDriver)
33
+ return await driver.runningApp([]);
34
+ }
35
+ catch {
36
+ /* ignore */
37
+ }
38
+ return undefined;
39
+ }
40
+ // ── Android ───────────────────────────────────────────────────────────────────
41
+ function parseAndroidMeminfo(out) {
42
+ const get = (key) => {
43
+ const m = out.match(new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, 'm'));
44
+ return m ? Number(m[1]) * 1024 : undefined;
45
+ };
46
+ return {
47
+ totalBytes: get('MemTotal'),
48
+ availableBytes: get('MemAvailable'),
49
+ freeBytes: get('MemFree'),
50
+ cachedBytes: get('Cached'),
51
+ swapTotalBytes: get('SwapTotal'),
52
+ swapFreeBytes: get('SwapFree'),
53
+ };
54
+ }
55
+ function parseAndroidDumpsysMeminfo(out) {
56
+ const app = {};
57
+ const objects = {};
58
+ const pidMatch = out.match(/\*\* MEMINFO in pid (\d+)/);
59
+ const pid = pidMatch ? Number(pidMatch[1]) : undefined;
60
+ // App Summary block — most useful, single-line entries with KB values.
61
+ // e.g. " Java Heap: 12345"
62
+ const summary = out.match(/App Summary[\s\S]*?(?:\n\s*\n|TOTAL:)/);
63
+ if (summary) {
64
+ const block = summary[0];
65
+ const grab = (label) => {
66
+ const m = block.match(new RegExp(`${label}:\\s+(\\d+)`));
67
+ return m ? Number(m[1]) * 1024 : undefined;
68
+ };
69
+ app.javaHeapBytes = grab('Java Heap');
70
+ app.nativeHeapBytes = grab('Native Heap');
71
+ app.codeBytes = grab('Code');
72
+ app.stackBytes = grab('Stack');
73
+ app.graphicsBytes = grab('Graphics');
74
+ app.privateOtherBytes = grab('Private Other');
75
+ app.systemBytes = grab('System');
76
+ const totalPss = block.match(/TOTAL PSS:\s+(\d+)/);
77
+ if (totalPss)
78
+ app.totalPssBytes = Number(totalPss[1]) * 1024;
79
+ const totalRss = block.match(/TOTAL RSS:\s+(\d+)/);
80
+ if (totalRss)
81
+ app.totalRssBytes = Number(totalRss[1]) * 1024;
82
+ const totalSwap = block.match(/TOTAL SWAP[^:]*:\s+(\d+)/);
83
+ if (totalSwap)
84
+ app['totalSwapBytes'] = Number(totalSwap[1]) * 1024;
85
+ }
86
+ // Fallback: TOTAL line in the main table — "TOTAL 12345 ..."
87
+ if (app.totalPssBytes === undefined) {
88
+ const total = out.match(/^\s*TOTAL\s+(\d+)/m);
89
+ if (total)
90
+ app.totalPssBytes = Number(total[1]) * 1024;
91
+ }
92
+ // Objects block:
93
+ // Objects
94
+ // Views: 3 ViewRootImpl: 1
95
+ // AppContexts: 2 Activities: 1
96
+ // Assets: 7 AssetManagers: 0
97
+ // Local Binders: 5 Proxy Binders: 17
98
+ // Parcel memory: 1 Parcel count: 4
99
+ // Death Recipients: 0 OpenSSL Sockets: 0
100
+ // WebViews: 0
101
+ const objSection = out.match(/Objects[\s\S]*?(?:\n\s*\n|SQL\s|DATABASES|$)/);
102
+ if (objSection) {
103
+ const text = objSection[0];
104
+ // Capture every "Label: number" pair (labels may have spaces).
105
+ const re = /([A-Za-z][A-Za-z _]*?):\s+(\d+)/g;
106
+ let m;
107
+ while ((m = re.exec(text)) !== null) {
108
+ const key = m[1].trim();
109
+ if (key === 'Objects')
110
+ continue;
111
+ objects[key] = Number(m[2]);
112
+ }
113
+ }
114
+ return { app, objects, pid };
115
+ }
116
+ async function collectAndroid(deviceId, appId) {
117
+ const report = { platform: 'android', deviceId, appId, notes: [] };
118
+ const meminfo = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'cat', '/proc/meminfo']);
119
+ if (meminfo.success) {
120
+ const sys = parseAndroidMeminfo(meminfo.stdout);
121
+ report.system = {
122
+ ...sys,
123
+ usedBytes: sys.totalBytes !== undefined && sys.availableBytes !== undefined
124
+ ? sys.totalBytes - sys.availableBytes
125
+ : undefined,
126
+ };
127
+ }
128
+ else {
129
+ report.notes.push(`/proc/meminfo unavailable: ${meminfo.stderr.trim()}`);
130
+ }
131
+ if (appId) {
132
+ const dump = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'dumpsys', 'meminfo', appId]);
133
+ if (dump.success && !dump.stdout.includes('No process found')) {
134
+ const { app, objects, pid } = parseAndroidDumpsysMeminfo(dump.stdout);
135
+ report.app = app;
136
+ report.objects = objects;
137
+ report.pid = pid;
138
+ }
139
+ else {
140
+ report.notes.push(`dumpsys meminfo ${appId} returned no data — app may not be running`);
141
+ }
142
+ }
143
+ if (report.notes.length === 0)
144
+ delete report.notes;
145
+ return report;
146
+ }
147
+ // ── iOS / tvOS ────────────────────────────────────────────────────────────────
148
+ function parseVmStat(out) {
149
+ // vm_stat output uses "page size of 16384 bytes" and counts in pages.
150
+ const pageMatch = out.match(/page size of (\d+)/);
151
+ const pageSize = pageMatch ? Number(pageMatch[1]) : 4096;
152
+ const grab = (label) => {
153
+ const m = out.match(new RegExp(`${label}:\\s+(\\d+)`));
154
+ return m ? Number(m[1]) * pageSize : undefined;
155
+ };
156
+ const free = grab('Pages free');
157
+ const inactive = grab('Pages inactive');
158
+ const speculative = grab('Pages speculative');
159
+ const wired = grab('Pages wired down');
160
+ const active = grab('Pages active');
161
+ const compressed = grab('Pages occupied by compressor');
162
+ const total = [free, inactive, speculative, wired, active, compressed]
163
+ .filter((v) => v !== undefined)
164
+ .reduce((a, b) => a + b, 0);
165
+ const available = free !== undefined && inactive !== undefined && speculative !== undefined
166
+ ? free + inactive + speculative
167
+ : undefined;
168
+ return {
169
+ totalBytes: total > 0 ? total : undefined,
170
+ freeBytes: free,
171
+ availableBytes: available,
172
+ };
173
+ }
174
+ async function findIOSPid(deviceId, appId) {
175
+ // Inside-simulator PIDs == host PIDs for app processes.
176
+ const list = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'spawn', deviceId, 'launchctl', 'list']);
177
+ if (!list.success)
178
+ return undefined;
179
+ for (const line of list.stdout.split('\n')) {
180
+ if (!line.includes(appId))
181
+ continue;
182
+ const parts = line.trim().split(/\s+/);
183
+ const pid = Number(parts[0]);
184
+ if (Number.isFinite(pid) && pid > 0)
185
+ return pid;
186
+ }
187
+ return undefined;
188
+ }
189
+ function parseVmmapSummary(out) {
190
+ const regions = {};
191
+ const app = {};
192
+ // Lines look like: "MALLOC 1.2G 234.5M ..."
193
+ // We want the "RESIDENT" column (2nd numeric). The columns are whitespace-delimited.
194
+ // Easier: parse the per-region rows under "REGION TYPE" header.
195
+ const headerIdx = out.indexOf('REGION TYPE');
196
+ if (headerIdx === -1)
197
+ return { app, regions };
198
+ const body = out.slice(headerIdx);
199
+ const lines = body.split('\n').slice(1);
200
+ for (const raw of lines) {
201
+ const line = raw.trimEnd();
202
+ if (!line.trim())
203
+ continue;
204
+ if (line.startsWith('='))
205
+ break;
206
+ if (line.startsWith('TOTAL')) {
207
+ // "TOTAL 5.2G 1.4G 950M ..."
208
+ const m = line.match(/TOTAL\s+\S+\s+(\S+)/);
209
+ if (m)
210
+ app.totalRssBytes = humanToBytes(m[1]);
211
+ break;
212
+ }
213
+ // Region rows: name (may have spaces), virtual, resident, dirty, swap, ...
214
+ const m = line.match(/^(.+?)\s{2,}(\S+)\s+(\S+)/);
215
+ if (!m)
216
+ continue;
217
+ const name = m[1].trim();
218
+ const resident = humanToBytes(m[3]);
219
+ if (resident !== undefined)
220
+ regions[name] = resident;
221
+ }
222
+ // Roll up a few well-known regions into the canonical fields.
223
+ app.nativeHeapBytes = regions['MALLOC'] ?? regions['MALLOC_NANO'];
224
+ app.stackBytes = regions['Stack'];
225
+ return { app, regions };
226
+ }
227
+ function humanToBytes(s) {
228
+ // Matches "1.2G", "234.5M", "950K", "1024", "1.2GB" etc.
229
+ const m = s.match(/^([\d.]+)\s*([KMGT]?)B?$/i);
230
+ if (!m)
231
+ return undefined;
232
+ const n = parseFloat(m[1]);
233
+ if (Number.isNaN(n))
234
+ return undefined;
235
+ const mult = {
236
+ '': 1,
237
+ K: 1024,
238
+ M: 1024 ** 2,
239
+ G: 1024 ** 3,
240
+ T: 1024 ** 4,
241
+ };
242
+ return Math.round(n * (mult[m[2].toUpperCase()] ?? 1));
243
+ }
244
+ async function collectIOS(deviceId, platform, appId) {
245
+ const report = { platform, deviceId, appId, notes: [] };
246
+ // System-wide memory: vm_stat from inside the simulator (== host RAM).
247
+ const vm = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'spawn', deviceId, 'vm_stat']);
248
+ if (vm.success) {
249
+ const sys = parseVmStat(vm.stdout);
250
+ report.system = {
251
+ ...sys,
252
+ usedBytes: sys.totalBytes !== undefined && sys.availableBytes !== undefined
253
+ ? sys.totalBytes - sys.availableBytes
254
+ : undefined,
255
+ };
256
+ report.notes.push('System memory reflects host Mac RAM — simulators share the host memory pool.');
257
+ }
258
+ else {
259
+ report.notes.push(`vm_stat unavailable: ${vm.stderr.trim()}`);
260
+ }
261
+ if (!appId) {
262
+ if (report.notes.length === 0)
263
+ delete report.notes;
264
+ return report;
265
+ }
266
+ const pid = await findIOSPid(deviceId, appId);
267
+ if (!pid) {
268
+ report.notes.push(`No running process found for ${appId}.`);
269
+ if (report.notes.length === 0)
270
+ delete report.notes;
271
+ return report;
272
+ }
273
+ report.pid = pid;
274
+ // ps for RSS/VSZ — fast, always available.
275
+ const ps = await (0, runner_js_1.spawnCommand)('ps', ['-o', 'rss=,vsz=', '-p', String(pid)]);
276
+ if (ps.success) {
277
+ const m = ps.stdout.trim().match(/(\d+)\s+(\d+)/);
278
+ if (m) {
279
+ report.app = {
280
+ ...(report.app ?? {}),
281
+ // ps reports rss/vsz in KB on macOS.
282
+ totalRssBytes: Number(m[1]) * 1024,
283
+ vszBytes: Number(m[2]) * 1024,
284
+ };
285
+ }
286
+ }
287
+ // vmmap -summary for region breakdown — slower but provides categories.
288
+ const vmmap = await (0, runner_js_1.spawnCommand)('vmmap', ['-summary', String(pid)]);
289
+ if (vmmap.success) {
290
+ const { app, regions } = parseVmmapSummary(vmmap.stdout);
291
+ report.app = {
292
+ ...(report.app ?? {}),
293
+ ...Object.fromEntries(Object.entries(app).filter(([, v]) => v !== undefined)),
294
+ regions,
295
+ };
296
+ }
297
+ else {
298
+ report.notes.push('vmmap unavailable — run `sudo DevToolsSecurity --enable` if it errors.');
299
+ }
300
+ if (report.notes.length === 0)
301
+ delete report.notes;
302
+ return report;
303
+ }
304
+ // ── Web (Playwright via CDP) ──────────────────────────────────────────────────
305
+ async function collectWeb(deviceId, sessionName) {
306
+ const report = { platform: 'web', deviceId, notes: [] };
307
+ let driver;
308
+ try {
309
+ const d = await (0, runner_js_1.getDriver)(sessionName);
310
+ if (!(d instanceof web_js_1.WebDriver)) {
311
+ report.notes.push('Expected web driver, got something else.');
312
+ return report;
313
+ }
314
+ driver = d;
315
+ }
316
+ catch (err) {
317
+ report.notes.push(`Could not attach to web driver: ${err instanceof Error ? err.message : String(err)}`);
318
+ return report;
319
+ }
320
+ const data = await driver.memory().catch((err) => {
321
+ report.notes.push(`Performance.getMetrics failed: ${err.message}`);
322
+ return null;
323
+ });
324
+ if (!data) {
325
+ if (report.notes.length === 0)
326
+ delete report.notes;
327
+ return report;
328
+ }
329
+ report.appId = data.url;
330
+ const m = data.metrics;
331
+ const pm = data.pageMemory;
332
+ report.app = {
333
+ // Heap totals — prefer page-context performance.memory (Chromium) over
334
+ // CDP's JSHeapUsedSize (which can lag). Both are JS heap only.
335
+ nativeHeapBytes: pm?.usedJSHeapSize ?? m['JSHeapUsedSize'],
336
+ codeBytes: m['JSHeapTotalSize'] ? m['JSHeapTotalSize'] - (m['JSHeapUsedSize'] ?? 0) : undefined,
337
+ // Roll the raw CDP metrics into `regions` so they're inspectable verbatim.
338
+ regions: { ...m },
339
+ };
340
+ if (pm) {
341
+ report.app.regions = {
342
+ ...report.app.regions,
343
+ 'JS Heap Used': pm.usedJSHeapSize,
344
+ 'JS Heap Total': pm.totalJSHeapSize,
345
+ 'JS Heap Limit': pm.jsHeapSizeLimit,
346
+ };
347
+ }
348
+ // Object counts — direct CDP equivalents of Android's "Views/Activities/Binders".
349
+ const objectKeys = [
350
+ 'Nodes',
351
+ 'Documents',
352
+ 'Frames',
353
+ 'JSEventListeners',
354
+ 'LayoutCount',
355
+ 'RecalcStyleCount',
356
+ ];
357
+ const objects = {};
358
+ for (const k of objectKeys) {
359
+ if (m[k] !== undefined)
360
+ objects[k] = m[k];
361
+ }
362
+ if (Object.keys(objects).length > 0)
363
+ report.objects = objects;
364
+ report.notes.push('Web memory is per-page (Performance.getMetrics + performance.memory). Run-wide RSS for the browser process is not exposed via CDP.');
365
+ if (report.notes.length === 0)
366
+ delete report.notes;
367
+ return report;
368
+ }
369
+ // ── Formatting ────────────────────────────────────────────────────────────────
370
+ function fmtBytes(n) {
371
+ if (n === undefined)
372
+ return '—';
373
+ if (n < 1024)
374
+ return `${n} B`;
375
+ const units = ['KB', 'MB', 'GB', 'TB'];
376
+ let v = n / 1024;
377
+ let i = 0;
378
+ while (v >= 1024 && i < units.length - 1) {
379
+ v /= 1024;
380
+ i++;
381
+ }
382
+ return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
383
+ }
384
+ function formatReport(r) {
385
+ const lines = [];
386
+ lines.push(`Device: ${r.deviceId} (${r.platform})`);
387
+ if (r.appId)
388
+ lines.push(`App: ${r.appId}${r.pid ? ` (pid ${r.pid})` : ''}`);
389
+ if (r.system) {
390
+ lines.push('');
391
+ lines.push('System memory:');
392
+ const s = r.system;
393
+ if (s.totalBytes !== undefined)
394
+ lines.push(` Total: ${fmtBytes(s.totalBytes)}`);
395
+ if (s.usedBytes !== undefined)
396
+ lines.push(` Used: ${fmtBytes(s.usedBytes)}`);
397
+ if (s.availableBytes !== undefined)
398
+ lines.push(` Available: ${fmtBytes(s.availableBytes)}`);
399
+ if (s.freeBytes !== undefined)
400
+ lines.push(` Free: ${fmtBytes(s.freeBytes)}`);
401
+ if (s.cachedBytes !== undefined)
402
+ lines.push(` Cached: ${fmtBytes(s.cachedBytes)}`);
403
+ if (s.swapTotalBytes !== undefined)
404
+ lines.push(` Swap: ${fmtBytes((s.swapTotalBytes ?? 0) - (s.swapFreeBytes ?? 0))} / ${fmtBytes(s.swapTotalBytes)}`);
405
+ }
406
+ if (r.app) {
407
+ lines.push('');
408
+ lines.push('App memory:');
409
+ const a = r.app;
410
+ if (a.totalPssBytes !== undefined)
411
+ lines.push(` PSS total: ${fmtBytes(a.totalPssBytes)}`);
412
+ if (a.totalRssBytes !== undefined)
413
+ lines.push(` RSS total: ${fmtBytes(a.totalRssBytes)}`);
414
+ if (a.totalUssBytes !== undefined)
415
+ lines.push(` USS total: ${fmtBytes(a.totalUssBytes)}`);
416
+ if (a.vszBytes !== undefined)
417
+ lines.push(` VSZ: ${fmtBytes(a.vszBytes)}`);
418
+ if (a.javaHeapBytes !== undefined)
419
+ lines.push(` Java heap: ${fmtBytes(a.javaHeapBytes)}`);
420
+ if (a.nativeHeapBytes !== undefined)
421
+ lines.push(` Native: ${fmtBytes(a.nativeHeapBytes)}`);
422
+ if (a.codeBytes !== undefined)
423
+ lines.push(` Code: ${fmtBytes(a.codeBytes)}`);
424
+ if (a.stackBytes !== undefined)
425
+ lines.push(` Stack: ${fmtBytes(a.stackBytes)}`);
426
+ if (a.graphicsBytes !== undefined)
427
+ lines.push(` Graphics: ${fmtBytes(a.graphicsBytes)}`);
428
+ if (a.privateOtherBytes !== undefined)
429
+ lines.push(` Private: ${fmtBytes(a.privateOtherBytes)}`);
430
+ if (a.systemBytes !== undefined)
431
+ lines.push(` System: ${fmtBytes(a.systemBytes)}`);
432
+ if (a.regions && Object.keys(a.regions).length > 0) {
433
+ lines.push('');
434
+ lines.push('Top memory regions (resident):');
435
+ const entries = Object.entries(a.regions)
436
+ .filter(([, v]) => v > 0)
437
+ .sort((a, b) => b[1] - a[1])
438
+ .slice(0, 12);
439
+ for (const [name, bytes] of entries) {
440
+ lines.push(` ${name.padEnd(28)} ${fmtBytes(bytes)}`);
441
+ }
442
+ }
443
+ }
444
+ if (r.objects && Object.keys(r.objects).length > 0) {
445
+ lines.push('');
446
+ lines.push('Object counts:');
447
+ const entries = Object.entries(r.objects).sort((a, b) => b[1] - a[1]);
448
+ for (const [name, count] of entries) {
449
+ lines.push(` ${name.padEnd(20)} ${count}`);
450
+ }
451
+ }
452
+ if (r.notes && r.notes.length > 0) {
453
+ lines.push('');
454
+ for (const n of r.notes)
455
+ lines.push(`note: ${n}`);
456
+ }
457
+ return lines.join('\n');
458
+ }
459
+ // ── Entry point ───────────────────────────────────────────────────────────────
460
+ async function memory(appIdArg, opts = {}, sessionName = 'default', _memOpts = {}) {
461
+ const deviceId = await resolveDeviceId(sessionName);
462
+ if (!deviceId) {
463
+ (0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
464
+ return 1;
465
+ }
466
+ const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
467
+ let report;
468
+ if (platform === 'web') {
469
+ report = await collectWeb(deviceId, sessionName);
470
+ }
471
+ else {
472
+ const appId = await resolveAppId(appIdArg, sessionName);
473
+ if (platform === 'android') {
474
+ report = await collectAndroid(deviceId, appId);
475
+ }
476
+ else {
477
+ report = await collectIOS(deviceId, platform, appId);
478
+ }
479
+ }
480
+ if (opts.json) {
481
+ (0, output_js_1.printData)({ status: 'ok', ...report }, opts);
482
+ }
483
+ else {
484
+ console.log(formatReport(report));
485
+ }
486
+ return 0;
487
+ }
@@ -839,6 +839,35 @@ async function handleRequest(req, res, dlog) {
839
839
  jsonResponse(res, { isScreenStatic: snap1 === snap2 });
840
840
  return;
841
841
  }
842
+ case '/memory': {
843
+ const p = await getPage(dlog);
844
+ const session = await p.context().newCDPSession(p);
845
+ try {
846
+ await session.send('Performance.enable').catch(() => { });
847
+ const perf = (await session.send('Performance.getMetrics'));
848
+ const metricsMap = {};
849
+ for (const m of perf.metrics)
850
+ metricsMap[m.name] = m.value;
851
+ // Chrome-only: performance.memory in the page context.
852
+ const pageMemory = await p
853
+ .evaluate(() => {
854
+ const pm = performance.memory;
855
+ return pm
856
+ ? {
857
+ usedJSHeapSize: pm.usedJSHeapSize,
858
+ totalJSHeapSize: pm.totalJSHeapSize,
859
+ jsHeapSizeLimit: pm.jsHeapSizeLimit,
860
+ }
861
+ : null;
862
+ })
863
+ .catch(() => null);
864
+ jsonResponse(res, { metrics: metricsMap, pageMemory, url: p.url() });
865
+ }
866
+ finally {
867
+ await session.detach().catch(() => { });
868
+ }
869
+ return;
870
+ }
842
871
  case '/consoleLogs': {
843
872
  const since = parsedUrl.query['since'] ?? '';
844
873
  const entries = since
@@ -0,0 +1,416 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.composeIOSAnnouncement = composeIOSAnnouncement;
4
+ exports.buildIOSA11y = buildIOSA11y;
5
+ exports.composeAndroidAnnouncement = composeAndroidAnnouncement;
6
+ exports.buildAndroidA11y = buildAndroidA11y;
7
+ exports.composeWebAnnouncement = composeWebAnnouncement;
8
+ exports.buildWebA11y = buildWebA11y;
9
+ const element_resolver_js_1 = require("./element-resolver.js");
10
+ // ── iOS ──────────────────────────────────────────────────────────────────────
11
+ /** XCUIElementType raw values → trait/role strings. Incomplete by design: only
12
+ * the element types that map to user-facing roles are listed; others fall back
13
+ * to an empty traits array and an empty role string. */
14
+ const IOS_TYPE_TO_TRAIT = {
15
+ 3: 'application',
16
+ 4: 'window',
17
+ 8: 'image',
18
+ 9: 'button',
19
+ 23: 'adjustable',
20
+ 40: 'switch',
21
+ 48: 'staticText',
22
+ 49: 'textField',
23
+ 50: 'secureTextField',
24
+ 54: 'link',
25
+ 70: 'table',
26
+ 73: 'picker',
27
+ 74: 'pickerWheel',
28
+ 75: 'cell',
29
+ 90: 'stepper',
30
+ 93: 'searchField',
31
+ };
32
+ function iosTraitsFor(node) {
33
+ const out = [];
34
+ const base = IOS_TYPE_TO_TRAIT[node.elementType];
35
+ if (base)
36
+ out.push(base);
37
+ if (node.selected)
38
+ out.push('selected');
39
+ if (!node.enabled)
40
+ out.push('disabled');
41
+ if (node.hasFocus)
42
+ out.push('focused');
43
+ return out;
44
+ }
45
+ function isIOSA11yElement(node) {
46
+ // VoiceOver considers an element "accessible" if it has a label or a known interactive type.
47
+ // AXElement doesn't expose isAccessibilityElement directly; this is a documented approximation.
48
+ const hasText = !!(node.label || node.title || node.value || node.placeholderValue);
49
+ const hasTrait = !!IOS_TYPE_TO_TRAIT[node.elementType];
50
+ const hasFrame = node.frame.Width > 0 && node.frame.Height > 0;
51
+ return hasFrame && (hasText || hasTrait);
52
+ }
53
+ /** iOS announcement: `[label], [traits], [value], [hint]` — commas between parts. */
54
+ function composeIOSAnnouncement(label, traits, value, hint) {
55
+ const parts = [];
56
+ if (label)
57
+ parts.push(label);
58
+ const announceableTraits = traits.filter((t) => t !== 'staticText' && t !== 'window' && t !== 'application');
59
+ if (announceableTraits.length)
60
+ parts.push(announceableTraits.join(', '));
61
+ if (value && value !== label)
62
+ parts.push(value);
63
+ if (hint)
64
+ parts.push(hint);
65
+ return parts.join(', ');
66
+ }
67
+ function buildIOSA11y(root) {
68
+ const snapshot = [];
69
+ let order = 0;
70
+ function walk(node, path) {
71
+ const traits = iosTraitsFor(node);
72
+ const isA11y = isIOSA11yElement(node);
73
+ const label = node.label || node.title || '';
74
+ const value = node.value || node.placeholderValue || '';
75
+ const hint = node.hint || '';
76
+ const announcement = isA11y ? composeIOSAnnouncement(label, traits, value, hint) : '';
77
+ let accessibilityOrder = null;
78
+ if (isA11y) {
79
+ accessibilityOrder = order++;
80
+ snapshot.push({
81
+ nodeId: path,
82
+ order: accessibilityOrder,
83
+ frame: {
84
+ x: node.frame.X,
85
+ y: node.frame.Y,
86
+ w: node.frame.Width,
87
+ h: node.frame.Height,
88
+ },
89
+ label,
90
+ hint,
91
+ role: traits[0] ?? '',
92
+ traits,
93
+ announcement,
94
+ value,
95
+ state: {
96
+ enabled: node.enabled,
97
+ selected: node.selected,
98
+ focused: node.hasFocus,
99
+ },
100
+ });
101
+ }
102
+ const children = (node.children ?? []).map((c, i) => walk(c, path === '' ? String(i) : `${path}.${i}`));
103
+ const enriched = {
104
+ ...node,
105
+ nodeId: path,
106
+ accessibilityOrder,
107
+ traits,
108
+ isAccessibilityElement: isA11y,
109
+ accessibilityIdentifier: node.identifier,
110
+ accessibilityLabel: label || undefined,
111
+ accessibilityHint: hint || undefined,
112
+ accessibilityValue: value || undefined,
113
+ announcement: announcement || undefined,
114
+ children: children.length ? children : undefined,
115
+ };
116
+ return enriched;
117
+ }
118
+ const hierarchy = walk(root, '0');
119
+ return { hierarchy, a11ySnapshot: snapshot };
120
+ }
121
+ // Android className → semantic role. Matches AccessibilityNodeInfoCompat defaults.
122
+ const ANDROID_CLASS_ROLE = [
123
+ [/Button$/, 'button'],
124
+ [/ImageButton$/, 'button'],
125
+ [/EditText$/, 'textField'],
126
+ [/CheckBox$/, 'checkbox'],
127
+ [/Switch$/, 'switch'],
128
+ [/ToggleButton$/, 'switch'],
129
+ [/RadioButton$/, 'radio'],
130
+ [/Spinner$/, 'dropdown'],
131
+ [/SeekBar$/, 'adjustable'],
132
+ [/ProgressBar$/, 'progressIndicator'],
133
+ [/TextView$/, 'staticText'],
134
+ [/ImageView$/, 'image'],
135
+ [/WebView$/, 'webView'],
136
+ ];
137
+ function androidRoleFor(className) {
138
+ for (const [re, role] of ANDROID_CLASS_ROLE) {
139
+ if (re.test(className))
140
+ return role;
141
+ }
142
+ return '';
143
+ }
144
+ /** Android announcement: `[contentDescription || text], [role], [state], [hint]`. */
145
+ function composeAndroidAnnouncement(text, contentDescription, role, stateParts, hint) {
146
+ const parts = [];
147
+ const spoken = contentDescription || text;
148
+ if (spoken)
149
+ parts.push(spoken);
150
+ if (role)
151
+ parts.push(role);
152
+ if (stateParts.length)
153
+ parts.push(stateParts.join(', '));
154
+ if (hint)
155
+ parts.push(hint);
156
+ return parts.join(', ');
157
+ }
158
+ /**
159
+ * Parse Android XML while preserving nesting — parseAndroidHierarchy returns a flat list,
160
+ * but we need tree structure for stable nodeId paths. We re-walk the XML here with a small
161
+ * state machine (open-tag depth) to reconstruct parent/child relationships.
162
+ */
163
+ function parseAndroidTree(xml) {
164
+ const flat = (0, element_resolver_js_1.parseAndroidHierarchy)(xml);
165
+ // Parent index per node, computed from ordered XML scan.
166
+ const openStack = [];
167
+ const parentOf = new Array(flat.length).fill(-1);
168
+ // Regex that walks open/close tags in order.
169
+ const tokenRe = /<node\b[^>]*?(\/?)>|<\/node>/g;
170
+ let idx = 0;
171
+ let m;
172
+ while ((m = tokenRe.exec(xml)) !== null) {
173
+ const tag = m[0];
174
+ if (tag === '</node>') {
175
+ openStack.pop();
176
+ continue;
177
+ }
178
+ const selfClosing = m[1] === '/';
179
+ // Does this node have a valid bounds? Only those appear in `flat`.
180
+ const boundsMatch = /\bbounds="(\[[^"]+\])"/.exec(tag);
181
+ const hasValidBounds = boundsMatch &&
182
+ /\[(\d+),(\d+)]\[(\d+),(\d+)]/.test(boundsMatch[1]) &&
183
+ (() => {
184
+ const [, x1, y1, x2, y2] = /\[(\d+),(\d+)]\[(\d+),(\d+)]/.exec(boundsMatch[1]);
185
+ return +x2 - +x1 > 0 && +y2 - +y1 > 0;
186
+ })();
187
+ if (hasValidBounds) {
188
+ parentOf[idx] = openStack.length ? openStack[openStack.length - 1] : -1;
189
+ if (!selfClosing)
190
+ openStack.push(idx);
191
+ idx++;
192
+ }
193
+ else if (!selfClosing) {
194
+ // Non-visible container: push a sentinel so close tags still pop correctly.
195
+ openStack.push(-1);
196
+ }
197
+ }
198
+ const childrenOf = new Map();
199
+ const roots = [];
200
+ parentOf.forEach((p, i) => {
201
+ if (p === -1)
202
+ roots.push(i);
203
+ else {
204
+ const arr = childrenOf.get(p) ?? [];
205
+ arr.push(i);
206
+ childrenOf.set(p, arr);
207
+ }
208
+ });
209
+ const result = flat.map((n) => ({
210
+ text: n.text,
211
+ resourceId: n.resourceId,
212
+ contentDesc: n.contentDesc,
213
+ className: n.className,
214
+ hintText: n.hintText,
215
+ bounds: n.bounds,
216
+ clickable: n.clickable,
217
+ focusable: n.focusable,
218
+ enabled: n.enabled,
219
+ checked: n.checked,
220
+ checkable: n.checkable,
221
+ focused: n.focused,
222
+ selected: n.selected,
223
+ visibleToUser: n.visibleToUser,
224
+ }));
225
+ result.childrenOf = childrenOf;
226
+ result.roots = roots;
227
+ return result;
228
+ }
229
+ function buildAndroidA11y(xml) {
230
+ const tree = parseAndroidTree(xml);
231
+ const snapshot = [];
232
+ const meta = new Array(tree.length);
233
+ const computeMeta = (rawIdx) => {
234
+ const n = tree[rawIdx];
235
+ const role = androidRoleFor(n.className);
236
+ const screenReaderFocusable = n.focusable && (!!n.text || !!n.contentDesc);
237
+ const importantForAccessibility = screenReaderFocusable ? 'yes' : 'auto';
238
+ const stateParts = [];
239
+ if (n.checkable)
240
+ stateParts.push(n.checked ? 'checked' : 'not checked');
241
+ if (n.selected)
242
+ stateParts.push('selected');
243
+ if (!n.enabled)
244
+ stateParts.push('disabled');
245
+ const announcement = composeAndroidAnnouncement(n.text, n.contentDesc, role, stateParts, n.hintText);
246
+ // importantForAccessibility is inferred as 'yes' or 'auto' here (uiautomator
247
+ // dumps omit 'no' nodes). The 'no' case is kept as a documented future extension.
248
+ const wouldAnnounce = n.visibleToUser && (!!n.text || !!n.contentDesc || screenReaderFocusable);
249
+ return {
250
+ role,
251
+ screenReaderFocusable,
252
+ importantForAccessibility,
253
+ announcement,
254
+ stateParts,
255
+ wouldAnnounce,
256
+ hasAnnouncedDescendant: false,
257
+ };
258
+ };
259
+ // Post-order pass to fill hasAnnouncedDescendant.
260
+ const postOrder = (rawIdx) => {
261
+ meta[rawIdx] = computeMeta(rawIdx);
262
+ let anyChild = false;
263
+ for (const c of tree.childrenOf.get(rawIdx) ?? []) {
264
+ const childHasAnnounced = postOrder(c);
265
+ if (childHasAnnounced || meta[c].wouldAnnounce)
266
+ anyChild = true;
267
+ }
268
+ meta[rawIdx].hasAnnouncedDescendant = anyChild;
269
+ return anyChild;
270
+ };
271
+ for (const r of tree.roots)
272
+ postOrder(r);
273
+ // Phase 2: pre-order walk to emit snapshot entries + build enriched tree.
274
+ let order = 0;
275
+ const walk = (rawIdx, path) => {
276
+ const n = tree[rawIdx];
277
+ const m = meta[rawIdx];
278
+ const inOrder = m.wouldAnnounce && !m.hasAnnouncedDescendant;
279
+ let accessibilityOrder = null;
280
+ if (inOrder) {
281
+ accessibilityOrder = order++;
282
+ snapshot.push({
283
+ nodeId: path,
284
+ order: accessibilityOrder,
285
+ frame: {
286
+ x: n.bounds.x1,
287
+ y: n.bounds.y1,
288
+ w: n.bounds.x2 - n.bounds.x1,
289
+ h: n.bounds.y2 - n.bounds.y1,
290
+ },
291
+ label: n.contentDesc || n.text,
292
+ hint: n.hintText,
293
+ role: m.role,
294
+ traits: m.role ? [m.role] : [],
295
+ announcement: m.announcement,
296
+ value: '',
297
+ state: {
298
+ enabled: n.enabled,
299
+ selected: n.selected,
300
+ focused: n.focused,
301
+ checked: n.checkable ? n.checked : undefined,
302
+ },
303
+ });
304
+ }
305
+ const childIndices = tree.childrenOf.get(rawIdx) ?? [];
306
+ const kids = childIndices.map((i, ci) => walk(i, `${path}.${ci}`));
307
+ return {
308
+ nodeId: path,
309
+ accessibilityOrder,
310
+ class: n.className,
311
+ resourceId: n.resourceId,
312
+ text: n.text,
313
+ contentDescription: n.contentDesc,
314
+ hintText: n.hintText,
315
+ roleDescription: m.role,
316
+ role: m.role,
317
+ importantForAccessibility: m.importantForAccessibility,
318
+ screenReaderFocusable: m.screenReaderFocusable,
319
+ bounds: n.bounds,
320
+ state: {
321
+ enabled: n.enabled,
322
+ selected: n.selected,
323
+ focused: n.focused,
324
+ checked: n.checkable ? n.checked : undefined,
325
+ },
326
+ announcement: m.announcement,
327
+ children: kids.length ? kids : undefined,
328
+ };
329
+ };
330
+ const hierarchy = tree.roots.map((r, i) => walk(r, String(i)));
331
+ return { hierarchy, a11ySnapshot: snapshot };
332
+ }
333
+ const WEB_FOCUSABLE_ROLES = new Set([
334
+ 'button',
335
+ 'link',
336
+ 'textbox',
337
+ 'searchbox',
338
+ 'checkbox',
339
+ 'radio',
340
+ 'switch',
341
+ 'slider',
342
+ 'spinbutton',
343
+ 'combobox',
344
+ 'menuitem',
345
+ 'menuitemcheckbox',
346
+ 'menuitemradio',
347
+ 'tab',
348
+ 'option',
349
+ ]);
350
+ function isWebFocusable(node) {
351
+ return WEB_FOCUSABLE_ROLES.has(node.role);
352
+ }
353
+ /** Web announcement: `[accessibleName], [role], [state]` — screen readers read role after name. */
354
+ function composeWebAnnouncement(name, role, stateParts) {
355
+ const parts = [];
356
+ if (name)
357
+ parts.push(name);
358
+ if (role && role !== 'generic' && role !== 'none')
359
+ parts.push(role);
360
+ if (stateParts.length)
361
+ parts.push(stateParts.join(', '));
362
+ return parts.join(', ');
363
+ }
364
+ function buildWebA11y(hierarchy) {
365
+ const snapshot = [];
366
+ let order = 0;
367
+ function walk(nodes, basePath) {
368
+ return nodes.map((n, i) => {
369
+ const path = basePath === '' ? String(i) : `${basePath}.${i}`;
370
+ const focusable = isWebFocusable(n);
371
+ const stateParts = [];
372
+ if (n.checked)
373
+ stateParts.push('checked');
374
+ if (n.selected)
375
+ stateParts.push('selected');
376
+ if (!n.enabled)
377
+ stateParts.push('disabled');
378
+ const announcement = composeWebAnnouncement(n.name, n.role, stateParts);
379
+ const kids = n.children ? walk(n.children, path) : undefined;
380
+ let accessibilityOrder = null;
381
+ if (focusable && n.bounds && n.bounds.width > 0 && n.bounds.height > 0) {
382
+ accessibilityOrder = order++;
383
+ snapshot.push({
384
+ nodeId: path,
385
+ order: accessibilityOrder,
386
+ frame: { x: n.bounds.x, y: n.bounds.y, w: n.bounds.width, h: n.bounds.height },
387
+ label: n.name,
388
+ hint: '',
389
+ role: n.role,
390
+ traits: n.role ? [n.role] : [],
391
+ announcement,
392
+ value: '',
393
+ state: {
394
+ enabled: n.enabled,
395
+ selected: !!n.selected,
396
+ focused: n.focused,
397
+ checked: n.checked,
398
+ },
399
+ });
400
+ }
401
+ const enriched = {
402
+ ...n,
403
+ nodeId: path,
404
+ accessibilityOrder,
405
+ accessibleName: n.name,
406
+ ariaLabel: n.name, // Playwright's `name` is the computed accessible name; alias it.
407
+ ariaDescription: '',
408
+ focusable,
409
+ announcement,
410
+ children: kids,
411
+ };
412
+ return enriched;
413
+ });
414
+ }
415
+ return { hierarchy: walk(hierarchy.elements, ''), a11ySnapshot: snapshot };
416
+ }
@@ -136,6 +136,9 @@ class WebDriver {
136
136
  const result = await this.get('runningApp');
137
137
  return result.runningAppBundleId;
138
138
  }
139
+ async memory() {
140
+ return this.get('memory');
141
+ }
139
142
  async eraseAllText(count = 50) {
140
143
  await this.post('eraseText', { count });
141
144
  }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const scroll_js_1 = require("./commands/scroll.js");
18
18
  const swipe_js_1 = require("./commands/swipe.js");
19
19
  const assert_visible_js_1 = require("./commands/assert-visible.js");
20
20
  const screenshot_js_1 = require("./commands/screenshot.js");
21
+ const capture_ui_js_1 = require("./commands/capture-ui.js");
21
22
  const inspect_js_1 = require("./commands/inspect.js");
22
23
  const focused_js_1 = require("./commands/focused.js");
23
24
  const run_flow_js_1 = require("./commands/run-flow.js");
@@ -45,6 +46,7 @@ const start_device_js_1 = require("./commands/start-device.js");
45
46
  const stop_device_js_1 = require("./commands/stop-device.js");
46
47
  const delete_device_js_1 = require("./commands/delete-device.js");
47
48
  const logs_js_1 = require("./commands/logs.js");
49
+ const memory_js_1 = require("./commands/memory.js");
48
50
  const device_picker_js_1 = require("./device-picker.js");
49
51
  const update_check_js_1 = require("./update-check.js");
50
52
  const pkg_root_js_1 = require("./pkg-root.js");
@@ -79,6 +81,7 @@ const COMMAND_HELP = {
79
81
  'set-location': set_location_js_1.HELP,
80
82
  'set-orientation': set_orientation_js_1.HELP,
81
83
  'take-screenshot': screenshot_js_1.HELP,
84
+ 'capture-ui': capture_ui_js_1.HELP,
82
85
  inspect: inspect_js_1.HELP,
83
86
  focused: focused_js_1.HELP,
84
87
  'run-flow': run_flow_js_1.HELP,
@@ -94,6 +97,7 @@ const COMMAND_HELP = {
94
97
  'device-pool': device_pool_js_1.HELP,
95
98
  'run-parallel': run_parallel_js_1.HELP,
96
99
  logs: logs_js_1.HELP,
100
+ memory: memory_js_1.HELP,
97
101
  };
98
102
  const OPTIONS_HELP = `Options:
99
103
  --device <id> Target device ID (also keys the session and daemon)
@@ -454,6 +458,11 @@ async function main() {
454
458
  exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName);
455
459
  break;
456
460
  }
461
+ case 'capture-ui': {
462
+ const outPath = argv['output'];
463
+ exitCode = await (0, capture_ui_js_1.captureUI)(outPath, opts, sessionName);
464
+ break;
465
+ }
457
466
  case 'inspect':
458
467
  exitCode = await (0, inspect_js_1.inspect)(opts, sessionName, { dump: argv['dump'] });
459
468
  break;
@@ -475,6 +484,11 @@ async function main() {
475
484
  duration: argv['duration'] !== undefined ? Number(argv['duration']) : undefined,
476
485
  });
477
486
  break;
487
+ case 'memory': {
488
+ const appId = rest[0];
489
+ exitCode = await (0, memory_js_1.memory)(appId, opts, sessionName);
490
+ break;
491
+ }
478
492
  case 'run-flow': {
479
493
  const file = rest[0] ?? '';
480
494
  const rawEnv = argv['env'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conductor
3
- version: 0.10.0
3
+ version: 0.12.0
4
4
  description: "Token-efficient CLI for mobile UI testing (iOS simulator + Android emulator), designed for AI agents"
5
5
  metadata.openclaw:
6
6
  category: service
@@ -410,8 +410,54 @@ The hierarchy shows each element's type, text, and accessibility ID (resourceId
410
410
 
411
411
  ```bash
412
412
  conductor inspect
413
+ conductor inspect --dump # full hierarchy as JSON with a11y fields
413
414
  ```
414
415
 
416
+ `--dump` emits a11y-enriched JSON on all platforms. Each node carries
417
+ `accessibilityOrder` (0-based screen-reader nav index, or `null`), `traits` / `role`,
418
+ `nodeId` (stable per-capture path), and platform-specific fields:
419
+ `isAccessibilityElement` + `accessibilityLabel/Hint/Value` on iOS,
420
+ `contentDescription` + `screenReaderFocusable` + `importantForAccessibility` on Android,
421
+ `accessibleName` + `focusable` on web. Existing fields (`label`, `frame`, `identifier`,
422
+ `text`, `resource-id`, `bounds`, ...) are preserved.
423
+
424
+ ---
425
+
426
+ ### `capture-ui`
427
+
428
+ Bundle everything Argus needs for a single UI inspection: screenshot (PNG base64) +
429
+ full UI hierarchy (with a11y fields) + flat accessibility-order snapshot in one JSON
430
+ document. Intended primarily for programmatic consumers (Argus UI panel); humans
431
+ should prefer `inspect` + `take-screenshot`.
432
+
433
+ ```bash
434
+ conductor capture-ui # prints bundle as JSON to stdout
435
+ conductor capture-ui --output /tmp/capture.json # writes bundle to file
436
+ ```
437
+
438
+ Output shape:
439
+
440
+ ```json
441
+ {
442
+ "version": 1,
443
+ "capturedAt": "2026-04-22T12:34:56.000Z",
444
+ "device": { "platform": "ios", "deviceId": "...", "width": 390, "height": 844 },
445
+ "screenshot": { "kind": "composite", "encoding": "png", "data": "<base64>" },
446
+ "hierarchy": { /* platform-native hierarchy with a11y fields on each node */ },
447
+ "a11ySnapshot": [
448
+ { "nodeId": "0.2.1", "order": 0, "frame": {"x":0,"y":0,"w":80,"h":44},
449
+ "label": "Sign in", "hint": "", "role": "button", "traits": ["button"],
450
+ "announcement": "Sign in, button", "value": "",
451
+ "state": {"enabled": true, "selected": false, "focused": false} }
452
+ ],
453
+ "capabilities": { "perViewPixels": false, "depthData": false }
454
+ }
455
+ ```
456
+
457
+ `nodeId` is a stable per-capture path (dot-joined child indices) so Argus can
458
+ correlate flat snapshot entries back to hierarchy nodes. `capabilities.perViewPixels`
459
+ is `false` in v1; per-view pixel rendering is a future phase.
460
+
415
461
  ---
416
462
 
417
463
  ### `logs`
@@ -3,6 +3,6 @@ skills:
3
3
  path: conductor/SKILL.md
4
4
  description: "Token-efficient CLI for mobile UI testing, designed for AI agents"
5
5
  category: service
6
- version: 0.10.0
6
+ version: 0.12.0
7
7
  requires:
8
8
  bins: [conductor]