@mmerterden/multi-agent-toolkit-mcp 3.2.0 → 3.4.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/CHANGELOG.md CHANGED
@@ -15,6 +15,84 @@ Releases before this file exists are recorded in the git tags and commit history
15
15
 
16
16
  ---
17
17
 
18
+ ## 3.4.0
19
+
20
+ ### Fixed
21
+
22
+ - **The accessibility audits reported a clean screen they had never read.** On a
23
+ tree that came back empty they returned `elements_scanned: 0, total_issues: 0,
24
+ critical: 0, important: 0, warning: 0` - a full clean bill of health,
25
+ indistinguishable from an accessible screen, and the first reading is the one
26
+ anyone believes. Both audits now carry `measurable` and a `reason`, and an
27
+ unmeasurable run reports `total_issues: null`, never 0. A count of zero is a
28
+ measurement; none was taken.
29
+
30
+ - **The iOS tree was empty until the Simulator window had been activated.**
31
+ Measured on a live simulator: 1 node before activation, 28 after, and it stays
32
+ populated once the window is backgrounded again. The dumper now activates the
33
+ window itself. It also says what to do when Simulator.app is not running at
34
+ all, because booting a device with `simctl` is not enough - the accessibility
35
+ bridge lives in the UI app.
36
+
37
+ - **The iOS audit scored Simulator.app's own chrome as app findings.** The
38
+ dumped window carries the hardware buttons, the toolbar and the title text
39
+ beside the device screen, so Apple's 17x65pt Volume Up button was reported as
40
+ an app tap-target violation. The audit now scopes to the device screen, the
41
+ largest AXGroup child of the window. On a live Home screen this took the
42
+ result from 23 findings over 23 elements to 1 finding over 13: 22 of the 23
43
+ were the simulator's own interface.
44
+
45
+ - The scoring moved to `tools/a11y/` with a suite covering the degenerate tree,
46
+ the chrome exclusion, a scope that matches nothing, and the rule that an
47
+ element with no identifier is audited whatever the scope - you cannot scope
48
+ what you cannot identify, and skipping it would hide the finding that says so.
49
+
50
+ ### Note
51
+
52
+ This is the first of four stages. It buys nothing new for a blind user yet; it
53
+ makes the existing checks honest so the next stages can be trusted. Reading
54
+ order, traits and hints come next, then Apple's own `XCUIAccessibilityAudit`
55
+ (contrast, Dynamic Type, clipped text), then the Android side.
56
+
57
+ ## 3.3.0
58
+
59
+ ### Added
60
+
61
+ - `ios_leaks` and `android_meminfo`: the server had 84 tools and not one of them
62
+ looked at memory.
63
+
64
+ `ios_leaks` runs `/usr/bin/leaks` against a simulator or host process, by pid
65
+ or by bundle id. Simulator processes are ordinary host processes, so no
66
+ `simctl spawn` is needed; this was checked against a live simulator rather than
67
+ assumed. `mode: "diff"` reports only what is new since a memory graph saved by
68
+ an earlier call, which is the shape a regression gate wants, where a single
69
+ absolute count says little.
70
+
71
+ `android_meminfo` reads `dumpsys meminfo` (KB, no root) and `mode: "diff"`
72
+ compares two snapshots, because growth across the same flow is the signal a
73
+ leak produces.
74
+
75
+ Both report `measurable: false` with a reason instead of a clean result when
76
+ they could not look: an iOS target without `get-task-allow`, an Android package
77
+ with no running process. `leaks` exits 0 in the unmeasurable case, exactly as
78
+ it does for a clean run, so anything keying off the exit status calls an
79
+ uninspected process leak-free.
80
+
81
+ ### Fixed
82
+
83
+ - `run()` turned every non-zero exit into an `ERROR` string with the command's
84
+ output discarded. `leaks` exits 1 when it FINDS leaks, so the new tool would
85
+ have reported its most important result as "could not measure". `runCapture()`
86
+ returns what the command printed and lets the caller decide what the status
87
+ means. Existing callers are untouched.
88
+
89
+ ### Note
90
+
91
+ `android_meminfo`'s parser was written against the documented output shape on a
92
+ machine with no adb and no emulator. The `leaks` parser was written against live
93
+ output captured from real runs, including the leaking and the unmeasurable case.
94
+ The tests pin both shapes; the first real Android run is that half's measurement.
95
+
18
96
  ## 3.2.0
19
97
 
20
98
  ### Added
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
9
9
 
10
- **84 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
10
+ **86 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
11
11
 
12
12
  Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolkit-mcp` resolves with no auth, no token, no `~/.npmrc` setup.
13
13
 
@@ -17,6 +17,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
17
17
 
18
18
  **7 categories:**
19
19
  - **Device Control** (59 tools) - screenshot, tap, swipe, type, navigate, dark mode, locale, orientation, location, permissions, push notifications, real start/stop screen recording
20
+ - **Memory** (2 tools) - `ios_leaks` runs /usr/bin/leaks against a simulator or host process, snapshot or diff against a saved memory graph; `android_meminfo` reads dumpsys meminfo, snapshot or diff. Both report `measurable:false` rather than a clean result when they could not look
20
21
  - **Crash Diagnostics** (2 tools) - `ios_list_crashes` reads the host's DiagnosticReports, `android_list_crashes` dumps the adb crash buffer, both tail-bounded
21
22
  - **Accessibility Audit** (2 tools) - auto-detect missing labels, small tap targets, missing identifiers (scope filtering supported)
22
23
  - **Store Compliance** (5 tools) - App Store / Play Store readiness; **18-rule deep `ios_app_store_audit`** cross-references Apple ITMS error codes + App Store Review Guidelines (privacy manifest, required-reason API, Info.plist, code signing, entitlements, embedded SDK, IPv6, debug-tool leak, ...)
@@ -189,7 +190,7 @@ Run a scripted login flow in one MCP call:
189
190
 
190
191
  Returns per-step status so the agent can self-correct on failure.
191
192
 
192
- ### iOS Tools (38)
193
+ ### iOS Tools (39)
193
194
 
194
195
  | Tool | Description |
195
196
  |------|-------------|
@@ -232,7 +233,7 @@ Returns per-step status so the agent can self-correct on failure.
232
233
  | `ios_visual_diff` | Compare two PNG screenshots (pixelmatch). Returns diff_pct, pass/fail vs threshold, optional diff PNG. Useful for snapshot regressions across light/dark, locale, dynamic type |
233
234
  | `ios_list_crashes` | List recent crash reports from the host's DiagnosticReports - filter by process name, bound by age and count |
234
235
 
235
- ### Android Tools (30)
236
+ ### Android Tools (31)
236
237
 
237
238
  | Tool | Description |
238
239
  |------|-------------|
package/README.tr.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  🇬🇧 English: [README.md](./README.md)
9
9
 
10
- iOS Simulator, Android Emulator ve headless web kontrolü için **84 araç**. AI kodlama asistanının mobil uygulamalarını görmesini, onlarla etkileşime girmesini ve denetlemesini sağlayan bir MCP sunucusu - ayrıca tarayıcıları sürer, 18-kurallık bir App Store uyumluluk denetimi çalıştırır ve çok-adımlı batch akışlarını orkestre eder.
10
+ iOS Simulator, Android Emulator ve headless web kontrolü için **86 araç**. AI kodlama asistanının mobil uygulamalarını görmesini, onlarla etkileşime girmesini ve denetlemesini sağlayan bir MCP sunucusu - ayrıca tarayıcıları sürer, 18-kurallık bir App Store uyumluluk denetimi çalıştırır ve çok-adımlı batch akışlarını orkestre eder.
11
11
 
12
12
  **Public npm registry** üzerinden dağıtılır - `npx @mmerterden/multi-agent-toolkit-mcp`, auth'suz, token'sız, `~/.npmrc` ayarı gerekmeden çözülür.
13
13
 
@@ -189,7 +189,7 @@ Tek bir MCP çağrısında scriptlenmiş bir login akışı çalıştır:
189
189
 
190
190
  Agent'ın hata durumunda kendini düzeltebilmesi için adım-başına durum döner.
191
191
 
192
- ### iOS Araçları (38)
192
+ ### iOS Araçları (39)
193
193
 
194
194
  | Araç | Açıklama |
195
195
  |------|-------------|
@@ -232,7 +232,7 @@ Agent'ın hata durumunda kendini düzeltebilmesi için adım-başına durum dön
232
232
  | `ios_visual_diff` | İki PNG screenshot'ı karşılaştır (pixelmatch). diff_pct, threshold'a karşı pass/fail, opsiyonel diff PNG döner. Açık/koyu mod, locale, dynamic type genelinde snapshot regresyonları için kullanışlı |
233
233
  | `ios_list_crashes` | Host'un DiagnosticReports dizinindeki güncel crash raporlarını listele - süreç adına göre filtrele, yaş ve sayı ile sınırla |
234
234
 
235
- ### Android Araçları (30)
235
+ ### Android Araçları (31)
236
236
 
237
237
  | Araç | Açıklama |
238
238
  |------|-------------|
package/index.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  } from "./tools/ios-testflight/index.js";
30
30
  import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
31
31
  import { parseLaunchOutput } from "./tools/launch-time/index.js";
32
+ import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memory/index.js";
33
+ import { auditIosTree, auditAndroidDump } from "./tools/a11y/index.js";
32
34
  import { interactiveElements } from "./tools/ui-inspect/index.js";
33
35
  import { selectCrashReports } from "./tools/crash-logs/index.js";
34
36
  import {
@@ -78,6 +80,25 @@ function run(cmd, opts = {}) {
78
80
  }
79
81
  }
80
82
 
83
+ /**
84
+ * Run a command whose non-zero exit is a RESULT, not a failure.
85
+ *
86
+ * `leaks` exits 1 when it finds leaks. Through run() that becomes an ERROR
87
+ * string with the report discarded, and a parser reading it sees no summary
88
+ * line and concludes the target could not be measured - which turns "leaks
89
+ * found" into "nothing measured", the exact inversion the tool exists to avoid.
90
+ * Here the command's own output is returned whatever the status, and the
91
+ * caller decides what it means.
92
+ */
93
+ function runCapture(cmd, opts = {}) {
94
+ try {
95
+ return execSync(cmd, { encoding: "utf-8", timeout: 30000, ...opts }).trim();
96
+ } catch (e) {
97
+ const printed = `${e.stdout || ""}${e.stderr || ""}`.trim();
98
+ return printed || `${ERROR_PREFIX}${truncateError(e.message)}`;
99
+ }
100
+ }
101
+
81
102
  // Async twin of run() for tools that hold the child for minutes: execSync would
82
103
  // block the event loop, leaving the server unable to answer any other request
83
104
  // (or a cancellation) for the whole build. Same contract: trimmed stdout on
@@ -339,6 +360,7 @@ const IOS_TOOLS = [
339
360
  { name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
340
361
  { name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed), metrics (XCTMetric performance results as JSON). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests", "metrics"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" }, test_id: { type: "string", description: "mode=metrics only: scope to one test case or suite instead of every measured test" } }, required: ["id"] } },
341
362
  { name: "ios_visual_diff", description: "Compare two PNG screenshots. Returns JSON with diff_pct, pass/fail vs threshold, and an optional diff image. Use for snapshot regression checks across light/dark, locale, dynamic type variants.", inputSchema: { type: "object", properties: { baseline: { type: "string", description: "Path to baseline PNG" }, current: { type: "string", description: "Path to current PNG" }, threshold: { type: "number", description: "Per-pixel color threshold 0..1 (default 0.1, lower = stricter)" }, max_diff_pct: { type: "number", description: "Fail if diff exceeds this percent (default 1.0)" }, output: { type: "string", description: "Path to write diff PNG (optional)" } }, required: ["baseline", "current"] } },
363
+ { name: "ios_leaks", description: "Look for leaked memory in a running simulator (or host) process with /usr/bin/leaks. mode=snapshot reports the current leak count and bytes; mode=diff reports only leaks new since a saved memory graph, which is the shape a regression gate wants. Reports measurable:false when the target lacks get-task-allow rather than reporting it as clean - leaks exits 0 in that case, so an unmeasurable target and a clean one are indistinguishable by exit status. Debug builds are debuggable; Apple-signed apps are not.", inputSchema: { type: "object", properties: { pid: { type: "number", description: "Process id. Either this or bundle_id." }, bundle_id: { type: "string", description: "Bundle id of an app running on the booted simulator; its pid is resolved for you." }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_graph: { type: "string", description: "mode=diff: path to the memory graph saved by an earlier call" }, output_graph: { type: "string", description: "Save a memory graph here to use as a later baseline" } }, required: [] } },
342
364
  { name: "ios_list_crashes", description: "List recent crash reports from the host's ~/Library/Logs/DiagnosticReports - where simulator app crashes land. Filter by process name, bound by age and count.", inputSchema: { type: "object", properties: { app: { type: "string", description: "Only reports whose file name (the crashed process) contains this substring" }, since_min: { type: "number", description: "Only reports newer than this many minutes" }, limit: { type: "number", description: "Max reports returned, newest first (default 20)" } } } },
343
365
  ];
344
366
 
@@ -479,38 +501,27 @@ async function handleIOS(name, args, ctx = {}) {
479
501
  const script = join(__dirname, "ui-tree-dumper.swift");
480
502
  if (!existsSync(script)) return "ui-tree-dumper.swift not found";
481
503
  const depth = num(args.max_depth ?? 10, "max_depth");
482
- const scope = args.scope || null;
483
- const treeJson = run(`swift ${shq(script)} ${depth}`, { timeout: 15000 });
504
+ const treeJson = run(`swift ${shq(script)} ${depth}`, { timeout: 30000 });
505
+ let tree = null;
484
506
  try {
485
- const issues = [];
486
- let totalScanned = 0, totalSkipped = 0;
487
- function auditNode(node, path = "") {
488
- const loc = path ? `${path} > ${node.role}` : node.role;
489
- const w = node.frame?.w || 0, h = node.frame?.h || 0;
490
- const isInteractive = ["AXButton", "AXLink", "AXTextField", "AXTextArea", "AXCheckBox", "AXRadioButton", "AXSlider", "AXSwitch", "AXTab"].includes(node.role);
491
- if (isInteractive) {
492
- // Scope filter: skip elements outside scope
493
- if (scope && node.identifier && !node.identifier.startsWith(scope)) { totalSkipped++; if (node.children) node.children.forEach(c => auditNode(c, loc)); return; }
494
- if (scope && !node.identifier) { /* no identifier = can't scope, still audit */ }
495
- totalScanned++;
496
- if (!node.title && !node.description && !node.value) issues.push({ severity: "critical", issue: "Missing accessibility label", element: loc, identifier: node.identifier, frame: node.frame });
497
- if (!node.identifier) issues.push({ severity: "warning", issue: "Missing accessibility identifier (UI testing)", element: loc });
498
- if (w > 0 && h > 0 && (w < 44 || h < 44)) issues.push({ severity: "important", issue: `Tap target too small: ${w.toFixed(0)}x${h.toFixed(0)}pt (min 44x44)`, element: loc, identifier: node.identifier, frame: node.frame });
499
- }
500
- if (node.children) node.children.forEach(c => auditNode(c, loc));
501
- }
502
- const tree = JSON.parse(treeJson);
503
- auditNode(tree);
504
- return JSON.stringify({ scope: scope || "all", elements_scanned: totalScanned, elements_skipped: totalSkipped, total_issues: issues.length, critical: issues.filter(i => i.severity === "critical").length, important: issues.filter(i => i.severity === "important").length, warning: issues.filter(i => i.severity === "warning").length, issues }, null, 2);
505
- } catch (e) { return `ERROR parsing UI tree: ${e.message}\n\nRaw output:\n${treeJson?.substring(0, 500)}`; }
507
+ tree = JSON.parse(treeJson);
508
+ } catch {
509
+ tree = null;
510
+ }
511
+ const r = auditIosTree({ tree, scope: args.scope || null });
512
+ return JSON.stringify({
513
+ scope: r.scope,
514
+ measurable: r.measurable,
515
+ reason: r.reason,
516
+ elements_scanned: r.elementsScanned,
517
+ elements_skipped: r.elementsSkipped,
518
+ total_issues: r.totalIssues,
519
+ critical: r.critical,
520
+ important: r.important,
521
+ warning: r.warning,
522
+ issues: r.issues,
523
+ }, null, 2);
506
524
  }
507
- // `simctl keychain <device> biometric-enroll` / `biometric-match` do not
508
- // exist - `keychain` supports only add-root-cert/add-cert/reset. The only
509
- // available lever is the (undocumented) BiometricKit notification, which is
510
- // posted via notifyutil inside the simulator. notifyutil exits 0 even when
511
- // it cannot set or post the name, so its output has to be inspected: a
512
- // "Failed with code N" line means the notification did not land, and that
513
- // must be reported as a failure rather than as a simulated success.
514
525
  case "ios_biometric": {
515
526
  const d = iosDevice(args.device_id);
516
527
  const action = args.match ? "match" : "nomatch";
@@ -789,6 +800,42 @@ async function handleIOS(name, args, ctx = {}) {
789
800
  const report = JSON.stringify({ passed, diff_pct: parseFloat(diffPct.toFixed(4)), diff_pixels: diffPixels, total_pixels: total, threshold, max_diff_pct: maxPct, baseline: args.baseline, current: args.current, diff_image: diffImagePath }, null, 2);
790
801
  return diffImagePath ? fileResult(report, diffImagePath) : report;
791
802
  }
803
+ case "ios_leaks": {
804
+ let pid = Number.isInteger(args.pid) ? args.pid : null;
805
+ if (!pid && args.bundle_id) {
806
+ const d = iosDevice(args.device_id);
807
+ const out = run(`xcrun simctl spawn ${d} launchctl list 2>/dev/null | grep ${shq(sanitizeId(args.bundle_id))}`);
808
+ const m = out && out.match(/^(\d+)\s/m);
809
+ if (!m) return `ERROR: no running process for ${args.bundle_id} on the booted simulator; launch it first`;
810
+ pid = parseInt(m[1], 10);
811
+ }
812
+ if (!pid) return "ERROR: pass pid or bundle_id";
813
+
814
+ const mode = args.mode || "snapshot";
815
+ const graphOut = args.output_graph ? ` --outputGraph=${shq(args.output_graph)}` : "";
816
+ if (mode === "diff") {
817
+ if (!args.baseline_graph) return "ERROR: mode=diff needs baseline_graph, the memory graph saved by an earlier call";
818
+ if (!existsSync(args.baseline_graph)) return `ERROR: baseline graph not found at ${args.baseline_graph}`;
819
+ }
820
+ const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
821
+ // leaks exits 1 when it FINDS leaks, so a non-zero status is a result and
822
+ // not a failure. Everything below is decided from the parsed output.
823
+ const raw = runCapture(`leaks ${pid}${diffArg}${graphOut} 2>&1`, { timeout: 120000 });
824
+ const parsed = parseLeaksOutput(raw);
825
+ return JSON.stringify({
826
+ pid,
827
+ mode,
828
+ measurable: parsed.measurable,
829
+ reason: parsed.reason,
830
+ leak_count: parsed.leakCount,
831
+ leaked_bytes: parsed.leakedBytes,
832
+ nodes_malloced: parsed.nodesMalloced,
833
+ malloced_kb: parsed.mallocedKb,
834
+ root_leaks: parsed.roots.slice(0, 50),
835
+ root_leaks_truncated: parsed.roots.length > 50 ? parsed.roots.length - 50 : 0,
836
+ graph_saved_to: args.output_graph || null,
837
+ }, null, 2);
838
+ }
792
839
  case "ios_list_crashes": {
793
840
  const dir = join(homedir(), "Library", "Logs", "DiagnosticReports");
794
841
  if (!existsSync(dir)) return JSON.stringify({ dir, count: 0, reports: [] }, null, 2);
@@ -848,6 +895,7 @@ const ANDROID_TOOLS = [
848
895
  { name: "android_accessibility_audit", description: "Audit Android app accessibility: missing contentDescription, small touch targets (<48dp), missing resource-id. Use scope to filter by resource-id prefix.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, scope: { type: "string", description: "Filter: only audit elements whose resource-id contains this prefix (e.g. 'login_', 'com.example:id/login_'). Omit to audit all." } } } },
849
896
  { name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
850
897
  { name: "android_apk_audit", description: "Audit APK/AAB for Play Store compliance: debug flag, target SDK, permissions, signing, ProGuard", inputSchema: { type: "object", properties: { apk_path: { type: "string", description: "Path to .apk file" } }, required: ["apk_path"] } },
898
+ { name: "android_meminfo", description: "Read an Android app's memory via `adb shell dumpsys meminfo` (KB, no root). mode=snapshot returns the App Summary rows and totals; mode=diff compares two snapshots so growth across the same flow is visible, which is the signal a leak actually produces - a single absolute number says almost nothing. Reports measurable:false when the package has no running process rather than returning zeros.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_json: { type: "string", description: "mode=diff: the JSON returned by an earlier snapshot call" } }, required: ["package_name"] } },
851
899
  { name: "android_list_crashes", description: "Dump the Android crash log buffer (`adb logcat -b crash -d`), tail-bounded. Empty output means no crashes since the buffer was last cleared.", inputSchema: { type: "object", properties: { lines: { type: "number", description: "Max lines returned, from the end (default 200)" }, device_id: { type: "string" } } } },
852
900
  { name: "android_set_orientation", description: "Rotate the Android screen to portrait or landscape. Disables accelerometer rotation and pins user_rotation, so the device stays put until rotation is re-enabled. Accounts for the device's natural orientation (detected via wm size), so landscape-natural tablets rotate correctly too. No iOS counterpart: simctl exposes no rotation lever.", inputSchema: { type: "object", properties: { orientation: { type: "string", enum: ["portrait", "landscape"] }, device_id: { type: "string" } }, required: ["orientation"] } },
853
901
  ];
@@ -1004,34 +1052,20 @@ async function handleAndroid(name, args, ctx = {}) {
1004
1052
  const f = join(SCREENSHOT_DIR, `a11y_${Date.now()}.xml`);
1005
1053
  run(`adb ${df} pull /sdcard/_mcp_a11y.xml ${shq(f)}`);
1006
1054
  run(`adb ${df} shell rm /sdcard/_mcp_a11y.xml`);
1007
- if (!existsSync(f)) return "ERROR: UI dump failed";
1008
- const xml = readFileSync(f, "utf-8");
1009
- const scope = args.scope || null;
1010
- const issues = [];
1011
- let totalScanned = 0, totalSkipped = 0;
1012
- const nodeRegex = /<node[^>]*>/g;
1013
- let match;
1014
- while ((match = nodeRegex.exec(xml)) !== null) {
1015
- const node = match[0];
1016
- const cls = node.match(/class="([^"]*)"/)?.[1] || "";
1017
- const desc = node.match(/content-desc="([^"]*)"/)?.[1] || "";
1018
- const rid = node.match(/resource-id="([^"]*)"/)?.[1] || "";
1019
- const text = node.match(/text="([^"]*)"/)?.[1] || "";
1020
- const clickable = node.includes('clickable="true"');
1021
- const bounds = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
1022
- if (clickable) {
1023
- if (scope && rid && !rid.includes(scope)) { totalSkipped++; continue; }
1024
- totalScanned++;
1025
- if (!desc && !text) issues.push({ severity: "critical", issue: "Missing contentDescription", element: cls, resourceId: rid });
1026
- if (!rid) issues.push({ severity: "warning", issue: "Missing resource-id (UI testing)", element: cls });
1027
- if (bounds) {
1028
- const w = parseInt(bounds[3]) - parseInt(bounds[1]);
1029
- const h = parseInt(bounds[4]) - parseInt(bounds[2]);
1030
- if (w < 48 || h < 48) issues.push({ severity: "important", issue: `Touch target too small: ${w}x${h}dp (min 48x48)`, element: cls, resourceId: rid });
1031
- }
1032
- }
1033
- }
1034
- return JSON.stringify({ scope: scope || "all", elements_scanned: totalScanned, elements_skipped: totalSkipped, total_issues: issues.length, critical: issues.filter(i => i.severity === "critical").length, important: issues.filter(i => i.severity === "important").length, warning: issues.filter(i => i.severity === "warning").length, issues }, null, 2);
1055
+ const xml = existsSync(f) ? readFileSync(f, "utf-8") : "";
1056
+ const r = auditAndroidDump({ xml, scope: args.scope || null });
1057
+ return JSON.stringify({
1058
+ scope: r.scope,
1059
+ measurable: r.measurable,
1060
+ reason: r.reason,
1061
+ elements_scanned: r.elementsScanned,
1062
+ elements_skipped: r.elementsSkipped,
1063
+ total_issues: r.totalIssues,
1064
+ critical: r.critical,
1065
+ important: r.important,
1066
+ warning: r.warning,
1067
+ issues: r.issues,
1068
+ }, null, 2);
1035
1069
  }
1036
1070
  case "android_launch_time": {
1037
1071
  run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
@@ -1097,6 +1131,33 @@ async function handleAndroid(name, args, ctx = {}) {
1097
1131
  const warnings = findings.filter(f => f.status === "warning").length;
1098
1132
  return JSON.stringify({ apk: p, summary: { critical, warnings, passed: findings.filter(f => f.status === "pass").length, total_checks: findings.length }, verdict: critical > 0 ? "FAIL - critical issues must be fixed" : warnings > 0 ? "WARN - review warnings" : "PASS - ready for Play Store", findings }, null, 2);
1099
1133
  }
1134
+ case "android_meminfo": {
1135
+ const pkg = sanitizeId(args.package_name);
1136
+ const raw = runCapture(`adb ${df} shell dumpsys meminfo ${pkg} 2>&1`, { timeout: 30000 });
1137
+ const snapshot = parseMeminfoOutput(raw);
1138
+ const mode = args.mode || "snapshot";
1139
+ if (mode === "diff") {
1140
+ if (!args.baseline_json) return "ERROR: mode=diff needs baseline_json, the JSON returned by an earlier snapshot call";
1141
+ let before;
1142
+ try {
1143
+ before = JSON.parse(args.baseline_json);
1144
+ } catch {
1145
+ return "ERROR: baseline_json is not valid JSON";
1146
+ }
1147
+ const d = diffMeminfo(before, snapshot);
1148
+ return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: snapshot }, null, 2);
1149
+ }
1150
+ return JSON.stringify({
1151
+ package: args.package_name,
1152
+ mode,
1153
+ measurable: snapshot.measurable,
1154
+ reason: snapshot.reason,
1155
+ pss_kb: snapshot.pss,
1156
+ total_pss_kb: snapshot.totalPssKb,
1157
+ total_rss_kb: snapshot.totalRssKb,
1158
+ total_swap_kb: snapshot.totalSwapKb,
1159
+ }, null, 2);
1160
+ }
1100
1161
  case "android_list_crashes": {
1101
1162
  const lines = args.lines !== undefined ? num(args.lines, "lines") : 200;
1102
1163
  const out = run(`adb ${df} logcat -b crash -d | tail -${lines}`);
@@ -1472,17 +1533,23 @@ const ISSUE_LIST = {
1472
1533
  },
1473
1534
  };
1474
1535
 
1536
+ // The counts are nullable on purpose. An audit that could not read the tree
1537
+ // reports null, never 0: a count of zero is a measurement, and none was taken.
1538
+ // `measurable` is the field to branch on; the counts are only meaningful when
1539
+ // it is true.
1475
1540
  const ACCESSIBILITY_AUDIT_SCHEMA = {
1476
1541
  type: "object",
1477
- required: ["total_issues", "critical", "important", "warning", "issues"],
1542
+ required: ["measurable", "total_issues", "critical", "important", "warning", "issues"],
1478
1543
  properties: {
1479
1544
  scope: { type: "string" },
1545
+ measurable: { type: "boolean" },
1546
+ reason: { type: ["string", "null"] },
1480
1547
  elements_scanned: { type: "integer" },
1481
1548
  elements_skipped: { type: "integer" },
1482
- total_issues: { type: "integer" },
1483
- critical: { type: "integer" },
1484
- important: { type: "integer" },
1485
- warning: { type: "integer" },
1549
+ total_issues: { type: ["integer", "null"] },
1550
+ critical: { type: ["integer", "null"] },
1551
+ important: { type: ["integer", "null"] },
1552
+ warning: { type: ["integer", "null"] },
1486
1553
  issues: ISSUE_LIST,
1487
1554
  },
1488
1555
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.2.0",
4
- "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 84 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
3
+ "version": "3.4.0",
4
+ "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 86 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "node index.js",
13
- "test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/offload/__tests__/offload.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
13
+ "test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
14
14
  "gates": "bash scripts/gates.sh"
15
15
  },
16
16
  "keywords": [
@@ -0,0 +1,236 @@
1
+ /**
2
+ * a11y - accessibility audit scoring, split out of index.js so it can be tested.
3
+ *
4
+ * The audits used to return a full clean table for a tree they never read:
5
+ * `elements_scanned: 0, total_issues: 0, critical: 0, important: 0, warning: 0`.
6
+ * On iOS the tree comes from Simulator.app's host accessibility bridge, and that
7
+ * bridge stays empty until the Simulator window has been activated at least
8
+ * once, verified on a live simulator: 1 node before activation, 28 after. So
9
+ * "no issues" and "never looked" were the same answer, and the first is the one
10
+ * a reader believes.
11
+ *
12
+ * Every result therefore carries `measurable` and, when false, a `reason`. A
13
+ * result that could not be measured reports `totalIssues: null`, never 0.
14
+ */
15
+
16
+ const IOS_INTERACTIVE_ROLES = new Set([
17
+ "AXButton",
18
+ "AXLink",
19
+ "AXTextField",
20
+ "AXTextArea",
21
+ "AXCheckBox",
22
+ "AXRadioButton",
23
+ "AXSlider",
24
+ "AXPopUpButton",
25
+ "AXMenuButton",
26
+ "AXSwitch",
27
+ ]);
28
+
29
+ const IOS_MIN_TAP_PT = 44;
30
+ const ANDROID_MIN_TAP_DP = 48;
31
+
32
+ /**
33
+ * An accessibility tree with no children is not a screen without controls, it
34
+ * is a tree that was never populated. Callers must not score it.
35
+ *
36
+ * @param {object|null} tree - parsed ui-tree-dumper output
37
+ * @returns {boolean}
38
+ */
39
+ export function isDegenerateTree(tree) {
40
+ if (!tree || typeof tree !== "object") return true;
41
+ if (tree.error) return true;
42
+ const kids = Array.isArray(tree.children) ? tree.children : [];
43
+ return kids.length === 0;
44
+ }
45
+
46
+ /**
47
+ * The device screen inside Simulator.app's window.
48
+ *
49
+ * The dumped window holds the simulator's own chrome as direct children -
50
+ * hardware buttons (Action, Volume Up, Sleep/Wake), the toolbar, the title
51
+ * text - and the iOS app underneath a single large AXGroup. Verified on a live
52
+ * simulator: seven chrome children beside one 402x873 AXGroup with the app in
53
+ * it. Auditing the window whole reports Apple's 17x35pt Volume button as an
54
+ * app tap-target violation, which is a false positive in exactly the place an
55
+ * accessibility report has to be trusted.
56
+ *
57
+ * @param {object} tree
58
+ * @returns {object|null} the screen subtree, or null when it cannot be found
59
+ */
60
+ export function deviceScreenSubtree(tree) {
61
+ if (!tree || tree.role !== "AXWindow") return tree || null;
62
+ const groups = (tree.children || []).filter((c) => c && c.role === "AXGroup");
63
+ if (groups.length === 0) return null;
64
+ const area = (n) => (n.frame?.w || 0) * (n.frame?.h || 0);
65
+ return groups.reduce((best, c) => (area(c) > area(best) ? c : best), groups[0]);
66
+ }
67
+
68
+ /**
69
+ * @param {object} params
70
+ * @param {object|null} params.tree - parsed ui-tree-dumper output
71
+ * @param {string|null} [params.scope] - only audit identifiers with this prefix
72
+ * @returns {{measurable: boolean, reason: string|null, scope: string,
73
+ * elementsScanned: number, elementsSkipped: number,
74
+ * totalIssues: number|null, critical: number|null,
75
+ * important: number|null, warning: number|null, issues: object[]}}
76
+ */
77
+ export function auditIosTree({ tree, scope = null }) {
78
+ if (isDegenerateTree(tree)) {
79
+ return unmeasurable(
80
+ scope,
81
+ tree && tree.error
82
+ ? String(tree.error)
83
+ : "the accessibility tree came back empty; open Simulator.app and bring its window to the front, then retry - the host bridge does not populate until the window has been activated once",
84
+ );
85
+ }
86
+
87
+ const screen = deviceScreenSubtree(tree);
88
+ if (!screen) {
89
+ return unmeasurable(
90
+ scope,
91
+ "the window held no device screen group; the simulator may still be starting up",
92
+ );
93
+ }
94
+
95
+ const issues = [];
96
+ let elementsScanned = 0;
97
+ let elementsSkipped = 0;
98
+
99
+ const visit = (node, path = "") => {
100
+ if (!node || typeof node !== "object") return;
101
+ const loc = path ? `${path} > ${node.role}` : node.role;
102
+ const w = node.frame?.w || 0;
103
+ const h = node.frame?.h || 0;
104
+
105
+ if (IOS_INTERACTIVE_ROLES.has(node.role)) {
106
+ if (scope && node.identifier && !node.identifier.startsWith(scope)) {
107
+ elementsSkipped++;
108
+ (node.children || []).forEach((c) => visit(c, loc));
109
+ return;
110
+ }
111
+ elementsScanned++;
112
+ if (!node.title && !node.description && !node.value) {
113
+ issues.push({ severity: "critical", issue: "Missing accessibility label", element: loc, identifier: node.identifier || null });
114
+ }
115
+ if (!node.identifier) {
116
+ issues.push({ severity: "warning", issue: "Missing accessibility identifier (UI testing)", element: loc });
117
+ }
118
+ if (w > 0 && h > 0 && (w < IOS_MIN_TAP_PT || h < IOS_MIN_TAP_PT)) {
119
+ issues.push({ severity: "important", issue: `Tap target too small: ${Math.round(w)}x${Math.round(h)}pt (min ${IOS_MIN_TAP_PT}x${IOS_MIN_TAP_PT})`, element: loc, identifier: node.identifier || null });
120
+ }
121
+ }
122
+ (node.children || []).forEach((c) => visit(c, loc));
123
+ };
124
+ visit(screen);
125
+
126
+ // A populated window whose interactive elements were all filtered out is a
127
+ // scope that matched nothing, not a clean screen.
128
+ if (elementsScanned === 0) {
129
+ return unmeasurable(
130
+ scope,
131
+ scope
132
+ ? `no interactive element matched scope "${scope}"`
133
+ : "the tree was readable but held no interactive elements to audit",
134
+ elementsSkipped,
135
+ );
136
+ }
137
+
138
+ return {
139
+ measurable: true,
140
+ reason: null,
141
+ scope: scope || "all",
142
+ elementsScanned,
143
+ elementsSkipped,
144
+ ...tally(issues),
145
+ };
146
+ }
147
+
148
+ /**
149
+ * @param {object} params
150
+ * @param {string} params.xml - uiautomator dump output
151
+ * @param {string|null} [params.scope] - only audit resource-ids containing this
152
+ * @returns {object} same shape as auditIosTree
153
+ */
154
+ export function auditAndroidDump({ xml, scope = null }) {
155
+ const text = typeof xml === "string" ? xml : "";
156
+ if (!/<node\b/.test(text)) {
157
+ return unmeasurable(scope, "the uiautomator dump held no nodes; the dump failed or the screen was not ready");
158
+ }
159
+
160
+ const issues = [];
161
+ let elementsScanned = 0;
162
+ let elementsSkipped = 0;
163
+
164
+ for (const match of text.matchAll(/<node[^>]*>/g)) {
165
+ const node = match[0];
166
+ const attr = (name) => node.match(new RegExp(`${name}="([^"]*)"`))?.[1] || "";
167
+ if (!node.includes('clickable="true"')) continue;
168
+
169
+ const cls = attr("class");
170
+ const desc = attr("content-desc");
171
+ const rid = attr("resource-id");
172
+ const label = attr("text");
173
+
174
+ if (scope && rid && !rid.includes(scope)) {
175
+ elementsSkipped++;
176
+ continue;
177
+ }
178
+ elementsScanned++;
179
+
180
+ if (!desc && !label) issues.push({ severity: "critical", issue: "Missing contentDescription", element: cls, resourceId: rid || null });
181
+ if (!rid) issues.push({ severity: "warning", issue: "Missing resource-id (UI testing)", element: cls });
182
+
183
+ const bounds = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
184
+ if (bounds) {
185
+ const w = parseInt(bounds[3], 10) - parseInt(bounds[1], 10);
186
+ const h = parseInt(bounds[4], 10) - parseInt(bounds[2], 10);
187
+ if (w < ANDROID_MIN_TAP_DP || h < ANDROID_MIN_TAP_DP) {
188
+ issues.push({ severity: "important", issue: `Touch target too small: ${w}x${h}dp (min ${ANDROID_MIN_TAP_DP}x${ANDROID_MIN_TAP_DP})`, element: cls, resourceId: rid || null });
189
+ }
190
+ }
191
+ }
192
+
193
+ if (elementsScanned === 0) {
194
+ return unmeasurable(
195
+ scope,
196
+ scope ? `no clickable element matched scope "${scope}"` : "the dump was readable but held no clickable elements to audit",
197
+ elementsSkipped,
198
+ );
199
+ }
200
+
201
+ return {
202
+ measurable: true,
203
+ reason: null,
204
+ scope: scope || "all",
205
+ elementsScanned,
206
+ elementsSkipped,
207
+ ...tally(issues),
208
+ };
209
+ }
210
+
211
+ function tally(issues) {
212
+ const by = (s) => issues.filter((i) => i.severity === s).length;
213
+ return {
214
+ totalIssues: issues.length,
215
+ critical: by("critical"),
216
+ important: by("important"),
217
+ warning: by("warning"),
218
+ issues,
219
+ };
220
+ }
221
+
222
+ function unmeasurable(scope, reason, elementsSkipped = 0) {
223
+ return {
224
+ measurable: false,
225
+ reason,
226
+ scope: scope || "all",
227
+ elementsScanned: 0,
228
+ elementsSkipped,
229
+ // null rather than 0: a count of zero is a measurement, and none was taken.
230
+ totalIssues: null,
231
+ critical: null,
232
+ important: null,
233
+ warning: null,
234
+ issues: [],
235
+ };
236
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * memory - parsers for `leaks` (iOS/macOS) and `dumpsys meminfo` (Android).
3
+ *
4
+ * Lives here rather than inline in index.js because index.js connects its
5
+ * transport at import time and cannot be loaded by a test.
6
+ */
7
+
8
+ /**
9
+ * Parse `leaks <pid>` output.
10
+ *
11
+ * The exit code alone cannot be trusted, verified against leaks on macOS:
12
+ *
13
+ * no leaks exit 0, summary "Process N: 0 leaks for 0 total leaked bytes."
14
+ * leaks found exit 1, summary "Process N: 198 leaks for 811008 total leaked bytes."
15
+ * target not debuggable exit 0, a "not debuggable" line, and NO summary at all
16
+ *
17
+ * So exit 1 is a finding rather than a failure, and exit 0 is either a clean
18
+ * run or a run that never measured anything. A wrapper that reads the exit code
19
+ * reports the third case as clean, which is the worst possible answer: a green
20
+ * result from a process nothing inspected. `measurable` is therefore derived
21
+ * from the presence of the summary line, never from the exit status.
22
+ *
23
+ * A process is not debuggable when it lacks the get-task-allow entitlement.
24
+ * Debug builds carry it; Apple's own signed apps do not.
25
+ *
26
+ * @param {string} raw - stdout+stderr of `leaks`
27
+ * @returns {{measurable: boolean, reason: string|null, leakCount: number|null,
28
+ * leakedBytes: number|null, nodesMalloced: number|null,
29
+ * mallocedKb: number|null, roots: string[]}}
30
+ */
31
+ export function parseLeaksOutput(raw) {
32
+ const text = typeof raw === "string" ? raw : "";
33
+
34
+ const summary = text.match(/Process\s+\d+:\s+(\d+)\s+leaks?\s+for\s+(\d+)\s+total leaked bytes/);
35
+ const malloced = text.match(/Process\s+\d+:\s+(\d+)\s+nodes malloced for\s+(\d+)\s+KB/);
36
+
37
+ if (!summary) {
38
+ // Order matters: report the specific reason when leaks named one, so the
39
+ // caller can tell "I could not look" from "I looked and found nothing".
40
+ const notDebuggable = /is not debuggable/.test(text);
41
+ const noSuchProcess = /(No such process|Cannot examine process|does not exist)/i.test(text);
42
+ return {
43
+ measurable: false,
44
+ reason: notDebuggable
45
+ ? "target is not debuggable (no get-task-allow entitlement); build and run a Debug configuration"
46
+ : noSuchProcess
47
+ ? "no such process"
48
+ : "leaks produced no summary line",
49
+ leakCount: null,
50
+ leakedBytes: null,
51
+ nodesMalloced: malloced ? parseInt(malloced[1], 10) : null,
52
+ mallocedKb: malloced ? parseInt(malloced[2], 10) : null,
53
+ roots: [],
54
+ };
55
+ }
56
+
57
+ const roots = (text.match(/^\s*\d+\s+\([^)]*\)\s+ROOT LEAK:.*$/gm) || []).map((l) => l.trim());
58
+
59
+ return {
60
+ measurable: true,
61
+ reason: null,
62
+ leakCount: parseInt(summary[1], 10),
63
+ leakedBytes: parseInt(summary[2], 10),
64
+ nodesMalloced: malloced ? parseInt(malloced[1], 10) : null,
65
+ mallocedKb: malloced ? parseInt(malloced[2], 10) : null,
66
+ roots,
67
+ };
68
+ }
69
+
70
+ const MEMINFO_ROWS = [
71
+ ["Java Heap", "javaHeapKb"],
72
+ ["Native Heap", "nativeHeapKb"],
73
+ ["Code", "codeKb"],
74
+ ["Stack", "stackKb"],
75
+ ["Graphics", "graphicsKb"],
76
+ ["Private Other", "privateOtherKb"],
77
+ ["System", "systemKb"],
78
+ ];
79
+
80
+ /**
81
+ * Parse the App Summary block of `adb shell dumpsys meminfo <package>`.
82
+ *
83
+ * Every number in meminfo is KB. The App Summary block is the part worth
84
+ * reading for a leak signal: growth in Java Heap or Native Heap across two
85
+ * snapshots of the same flow is the shape a leak takes, where a single
86
+ * absolute number says almost nothing.
87
+ *
88
+ * NOT VERIFIED ON A DEVICE. Written against the documented output shape on a
89
+ * machine with no adb and no emulator, unlike the leaks parser above, which was
90
+ * checked against live output. The tests below pin the shape this expects; the
91
+ * first real run is the measurement.
92
+ *
93
+ * @param {string} raw
94
+ * @returns {{measurable: boolean, reason: string|null, pss: object, totalPssKb: number|null,
95
+ * totalRssKb: number|null, totalSwapKb: number|null}}
96
+ */
97
+ export function parseMeminfoOutput(raw) {
98
+ const text = typeof raw === "string" ? raw : "";
99
+
100
+ if (/No process found for/i.test(text)) {
101
+ return { measurable: false, reason: "no running process for that package", pss: {}, totalPssKb: null, totalRssKb: null, totalSwapKb: null };
102
+ }
103
+
104
+ const pss = {};
105
+ let found = 0;
106
+ for (const [label, key] of MEMINFO_ROWS) {
107
+ const m = text.match(new RegExp(`^\\s*${label}:\\s+(\\d+)`, "mi"));
108
+ if (m) {
109
+ pss[key] = parseInt(m[1], 10);
110
+ found++;
111
+ }
112
+ }
113
+
114
+ const num = (re) => {
115
+ const m = text.match(re);
116
+ return m ? parseInt(m[1], 10) : null;
117
+ };
118
+ const totalPssKb = num(/TOTAL PSS:\s*(\d+)/i);
119
+ const totalRssKb = num(/TOTAL RSS:\s*(\d+)/i);
120
+ const totalSwapKb = num(/TOTAL SWAP \(KB\):\s*(\d+)/i);
121
+
122
+ if (found === 0 && totalPssKb === null) {
123
+ return { measurable: false, reason: "no App Summary block in the dumpsys output", pss: {}, totalPssKb: null, totalRssKb: null, totalSwapKb: null };
124
+ }
125
+
126
+ return { measurable: true, reason: null, pss, totalPssKb, totalRssKb, totalSwapKb };
127
+ }
128
+
129
+ /**
130
+ * Difference between two meminfo snapshots, in KB.
131
+ *
132
+ * Only keys present in both are compared; a key missing from either side is
133
+ * absent from the result rather than counted as zero growth.
134
+ *
135
+ * @param {object} before - parseMeminfoOutput result
136
+ * @param {object} after - parseMeminfoOutput result
137
+ * @returns {{comparable: boolean, reason: string|null, deltaKb: object, totalPssDeltaKb: number|null}}
138
+ */
139
+ export function diffMeminfo(before, after) {
140
+ if (!before?.measurable || !after?.measurable) {
141
+ return { comparable: false, reason: "one of the snapshots was not measurable", deltaKb: {}, totalPssDeltaKb: null };
142
+ }
143
+ const deltaKb = {};
144
+ for (const key of Object.keys(after.pss)) {
145
+ if (typeof before.pss[key] === "number") deltaKb[key] = after.pss[key] - before.pss[key];
146
+ }
147
+ const totalPssDeltaKb =
148
+ typeof before.totalPssKb === "number" && typeof after.totalPssKb === "number"
149
+ ? after.totalPssKb - before.totalPssKb
150
+ : null;
151
+ return { comparable: true, reason: null, deltaKb, totalPssDeltaKb };
152
+ }
@@ -86,10 +86,22 @@ func dumpElement(_ element: AXUIElement, depth: Int, maxDepth: Int) -> AXNode? {
86
86
  )
87
87
  }
88
88
 
89
+ // The host accessibility bridge does not populate the iOS app's elements until
90
+ // the Simulator window has been activated at least once. Measured on a live
91
+ // simulator: 1 node before activation, 28 after, and it stays populated once
92
+ // the window has been backgrounded again. Without this the tree comes back
93
+ // holding only the window, which reads as a screen with no controls.
94
+ func activateSimulator(_ app: NSRunningApplication) {
95
+ if app.isActive { return }
96
+ app.activate(options: [])
97
+ Thread.sleep(forTimeInterval: 1.2)
98
+ }
99
+
89
100
  func findSimulatorWindow() -> AXUIElement? {
90
101
  let apps = NSWorkspace.shared.runningApplications
91
102
  for app in apps {
92
103
  if app.bundleIdentifier == "com.apple.iphonesimulator" {
104
+ activateSimulator(app)
93
105
  let axApp = AXUIElementCreateApplication(app.processIdentifier)
94
106
  var windows: AnyObject?
95
107
  AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windows)
@@ -105,7 +117,7 @@ func findSimulatorWindow() -> AXUIElement? {
105
117
  let maxDepth = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 10 : 10
106
118
 
107
119
  guard let simWindow = findSimulatorWindow() else {
108
- let error = ["error": "Simulator not running or no window found"]
120
+ let error = ["error": "Simulator.app is not running or has no window. Booting a device with simctl is not enough - the accessibility bridge lives in the Simulator UI app. Run: open -a Simulator"]
109
121
  let data = try! JSONSerialization.data(withJSONObject: error)
110
122
  FileHandle.standardOutput.write(data)
111
123
  exit(1)