@mmerterden/multi-agent-toolkit-mcp 3.3.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,45 @@ 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
+
18
57
  ## 3.3.0
19
58
 
20
59
  ### Added
package/index.js CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
31
31
  import { parseLaunchOutput } from "./tools/launch-time/index.js";
32
32
  import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memory/index.js";
33
+ import { auditIosTree, auditAndroidDump } from "./tools/a11y/index.js";
33
34
  import { interactiveElements } from "./tools/ui-inspect/index.js";
34
35
  import { selectCrashReports } from "./tools/crash-logs/index.js";
35
36
  import {
@@ -500,38 +501,27 @@ async function handleIOS(name, args, ctx = {}) {
500
501
  const script = join(__dirname, "ui-tree-dumper.swift");
501
502
  if (!existsSync(script)) return "ui-tree-dumper.swift not found";
502
503
  const depth = num(args.max_depth ?? 10, "max_depth");
503
- const scope = args.scope || null;
504
- const treeJson = run(`swift ${shq(script)} ${depth}`, { timeout: 15000 });
504
+ const treeJson = run(`swift ${shq(script)} ${depth}`, { timeout: 30000 });
505
+ let tree = null;
505
506
  try {
506
- const issues = [];
507
- let totalScanned = 0, totalSkipped = 0;
508
- function auditNode(node, path = "") {
509
- const loc = path ? `${path} > ${node.role}` : node.role;
510
- const w = node.frame?.w || 0, h = node.frame?.h || 0;
511
- const isInteractive = ["AXButton", "AXLink", "AXTextField", "AXTextArea", "AXCheckBox", "AXRadioButton", "AXSlider", "AXSwitch", "AXTab"].includes(node.role);
512
- if (isInteractive) {
513
- // Scope filter: skip elements outside scope
514
- if (scope && node.identifier && !node.identifier.startsWith(scope)) { totalSkipped++; if (node.children) node.children.forEach(c => auditNode(c, loc)); return; }
515
- if (scope && !node.identifier) { /* no identifier = can't scope, still audit */ }
516
- totalScanned++;
517
- if (!node.title && !node.description && !node.value) issues.push({ severity: "critical", issue: "Missing accessibility label", element: loc, identifier: node.identifier, frame: node.frame });
518
- if (!node.identifier) issues.push({ severity: "warning", issue: "Missing accessibility identifier (UI testing)", element: loc });
519
- 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 });
520
- }
521
- if (node.children) node.children.forEach(c => auditNode(c, loc));
522
- }
523
- const tree = JSON.parse(treeJson);
524
- auditNode(tree);
525
- 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);
526
- } 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);
527
524
  }
528
- // `simctl keychain <device> biometric-enroll` / `biometric-match` do not
529
- // exist - `keychain` supports only add-root-cert/add-cert/reset. The only
530
- // available lever is the (undocumented) BiometricKit notification, which is
531
- // posted via notifyutil inside the simulator. notifyutil exits 0 even when
532
- // it cannot set or post the name, so its output has to be inspected: a
533
- // "Failed with code N" line means the notification did not land, and that
534
- // must be reported as a failure rather than as a simulated success.
535
525
  case "ios_biometric": {
536
526
  const d = iosDevice(args.device_id);
537
527
  const action = args.match ? "match" : "nomatch";
@@ -1062,34 +1052,20 @@ async function handleAndroid(name, args, ctx = {}) {
1062
1052
  const f = join(SCREENSHOT_DIR, `a11y_${Date.now()}.xml`);
1063
1053
  run(`adb ${df} pull /sdcard/_mcp_a11y.xml ${shq(f)}`);
1064
1054
  run(`adb ${df} shell rm /sdcard/_mcp_a11y.xml`);
1065
- if (!existsSync(f)) return "ERROR: UI dump failed";
1066
- const xml = readFileSync(f, "utf-8");
1067
- const scope = args.scope || null;
1068
- const issues = [];
1069
- let totalScanned = 0, totalSkipped = 0;
1070
- const nodeRegex = /<node[^>]*>/g;
1071
- let match;
1072
- while ((match = nodeRegex.exec(xml)) !== null) {
1073
- const node = match[0];
1074
- const cls = node.match(/class="([^"]*)"/)?.[1] || "";
1075
- const desc = node.match(/content-desc="([^"]*)"/)?.[1] || "";
1076
- const rid = node.match(/resource-id="([^"]*)"/)?.[1] || "";
1077
- const text = node.match(/text="([^"]*)"/)?.[1] || "";
1078
- const clickable = node.includes('clickable="true"');
1079
- const bounds = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
1080
- if (clickable) {
1081
- if (scope && rid && !rid.includes(scope)) { totalSkipped++; continue; }
1082
- totalScanned++;
1083
- if (!desc && !text) issues.push({ severity: "critical", issue: "Missing contentDescription", element: cls, resourceId: rid });
1084
- if (!rid) issues.push({ severity: "warning", issue: "Missing resource-id (UI testing)", element: cls });
1085
- if (bounds) {
1086
- const w = parseInt(bounds[3]) - parseInt(bounds[1]);
1087
- const h = parseInt(bounds[4]) - parseInt(bounds[2]);
1088
- if (w < 48 || h < 48) issues.push({ severity: "important", issue: `Touch target too small: ${w}x${h}dp (min 48x48)`, element: cls, resourceId: rid });
1089
- }
1090
- }
1091
- }
1092
- 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);
1093
1069
  }
1094
1070
  case "android_launch_time": {
1095
1071
  run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
@@ -1557,17 +1533,23 @@ const ISSUE_LIST = {
1557
1533
  },
1558
1534
  };
1559
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.
1560
1540
  const ACCESSIBILITY_AUDIT_SCHEMA = {
1561
1541
  type: "object",
1562
- required: ["total_issues", "critical", "important", "warning", "issues"],
1542
+ required: ["measurable", "total_issues", "critical", "important", "warning", "issues"],
1563
1543
  properties: {
1564
1544
  scope: { type: "string" },
1545
+ measurable: { type: "boolean" },
1546
+ reason: { type: ["string", "null"] },
1565
1547
  elements_scanned: { type: "integer" },
1566
1548
  elements_skipped: { type: "integer" },
1567
- total_issues: { type: "integer" },
1568
- critical: { type: "integer" },
1569
- important: { type: "integer" },
1570
- warning: { type: "integer" },
1549
+ total_issues: { type: ["integer", "null"] },
1550
+ critical: { type: ["integer", "null"] },
1551
+ important: { type: ["integer", "null"] },
1552
+ warning: { type: ["integer", "null"] },
1571
1553
  issues: ISSUE_LIST,
1572
1554
  },
1573
1555
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
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",
@@ -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/memory/__tests__/memory.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
+ }
@@ -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)