@mmerterden/multi-agent-toolkit-mcp 3.5.0 → 3.7.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,70 @@ Releases before this file exists are recorded in the git tags and commit history
15
15
 
16
16
  ---
17
17
 
18
+ ## 3.7.0
19
+
20
+ ### Added
21
+
22
+ - **`android_accessibility_audit` gains screen-reader reachability and reading
23
+ order.** A clickable node with `focusable="false"` can be tapped by a sighted
24
+ user and never reached by TalkBack at all: the control is invisible to the
25
+ screen reader rather than merely unnamed, so it is a `critical` finding, above
26
+ a missing contentDescription. The reading-order check is the same
27
+ rows-then-columns comparison the iOS side uses, sharing one implementation,
28
+ with `rtl` for right-to-left layouts.
29
+
30
+ ### Fixed
31
+
32
+ - **A claim of mine in three places was wrong.** The notes on `android_meminfo`
33
+ and the 3.3.0 entry said this machine has no adb. It has adb; what it has no
34
+ Android device or emulator to run against. The distinction matters to anyone
35
+ reading those notes to decide what still needs verifying, so all three now say
36
+ what is actually missing.
37
+
38
+ ### Note
39
+
40
+ Stage 4 of 4, and the last one that could not be measured here. The iOS work was
41
+ verified against a live simulator at every step, including a generated app with
42
+ planted defects for Apple's own audit. The Android checks are written against
43
+ the documented `uiautomator dump` shape and pinned by fixtures; nothing on this
44
+ machine could run them against a device. The first real run is their measurement,
45
+ and the hierarchy attribute they depend on, `focusable`, is one uiautomator has
46
+ always emitted.
47
+
48
+ ## 3.6.0
49
+
50
+ ### Added
51
+
52
+ - **`ios_accessibility_audit_deep`: Apple's own accessibility audit.** Contrast,
53
+ Dynamic Type and clipped text cannot be reached from an accessibility tree
54
+ dump, whatever you do to the dumper. `XCUIAccessibilityAudit` can see them,
55
+ and it runs inside an XCUITest, so this is a separate tool rather than a mode
56
+ on the existing one: an `xcodebuild test` run, minutes rather than seconds,
57
+ needing a test target the tree-dump audit does not.
58
+
59
+ Verified end to end rather than written from the header. A throwaway SwiftUI
60
+ app with three planted defects was generated, built and driven on a live
61
+ simulator, and Apple's audit returned "Contrast failed", "Contrast nearly
62
+ passed" and "Element has no description". The parser was written against that
63
+ output, and the test fixture is those three messages in the exact result-bundle
64
+ shape `xcresulttool` emits.
65
+
66
+ The findings are classified by audit type. Anything the keyword table does not
67
+ recognise keeps its raw text under `unclassified` rather than being forced into
68
+ a bucket it may not belong to.
69
+
70
+ **The boundary, stated because it cannot be closed from here:** a test that
71
+ never calls `performAccessibilityAudit()` passes, and a passing test looks
72
+ exactly like an audit that found nothing. So the result reports whether the
73
+ named test RAN, the tool description says the test must perform the audit
74
+ itself, and `measurable` is false when the test did not run. What the tool
75
+ cannot do is prove the test called the API.
76
+
77
+ ### Note
78
+
79
+ Stage 3 of 4. The Android side is last and stays unverifiable here: this machine
80
+ has adb but no device or emulator to run it against.
81
+
18
82
  ## 3.5.0
19
83
 
20
84
  ### Added
@@ -51,7 +115,7 @@ Releases before this file exists are recorded in the git tags and commit history
51
115
  Stage 2 of 4. Contrast, Dynamic Type and clipped text still cannot be reached
52
116
  from a tree dump; they need Apple's own `XCUIAccessibilityAudit`, which is an
53
117
  XCUITest run rather than a simctl call. The Android side is stage 4 and is
54
- unverifiable on a machine with no adb.
118
+ unverifiable without a device or emulator to run adb against.
55
119
 
56
120
  ## 3.4.0
57
121
 
@@ -127,7 +191,8 @@ order, traits and hints come next, then Apple's own `XCUIAccessibilityAudit`
127
191
  ### Note
128
192
 
129
193
  `android_meminfo`'s parser was written against the documented output shape on a
130
- machine with no adb and no emulator. The `leaks` parser was written against live
194
+ machine with adb installed but no device or emulator to run it against. The
195
+ `leaks` parser was written against live
131
196
  output captured from real runs, including the leaking and the unmeasurable case.
132
197
  The tests pin both shapes; the first real Android run is that half's measurement.
133
198
 
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
9
9
 
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.
10
+ **87 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
 
@@ -19,7 +19,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
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
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
21
21
  - **Crash Diagnostics** (2 tools) - `ios_list_crashes` reads the host's DiagnosticReports, `android_list_crashes` dumps the adb crash buffer, both tail-bounded
22
- - **Accessibility Audit** (2 tools) - auto-detect missing labels, small tap targets, missing identifiers (scope filtering supported)
22
+ - **Accessibility Audit** (3 tools) - `ios_accessibility_audit` / `android_accessibility_audit` read the live tree for missing labels, controls a screen reader cannot name, small tap targets, missing identifiers and a reading order that does not follow the visual layout. `ios_accessibility_audit_deep` runs Apple's own XCUIAccessibilityAudit through your XCUITest for the things a tree cannot show: contrast, Dynamic Type, clipped text. All three report `measurable:false` with a reason rather than a clean result when they could not look
23
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, ...)
24
24
  - **Web Automation** (8 tools) - Playwright-powered: goto, click (CSS selectors), type, eval JS, wait for selector, extract text, screenshot. Chromium / WebKit / Firefox engines. **Requires `playwright` peer dependency.**
25
25
  - **Design Audit** (6 tools) - mock-mode vs Figma conformance: scenario inventory, mock detection, mock launch, live UI geometry, pixel/geometry/typography compare, and the HTML/PDF report with its coverage gate
@@ -190,7 +190,7 @@ Run a scripted login flow in one MCP call:
190
190
 
191
191
  Returns per-step status so the agent can self-correct on failure.
192
192
 
193
- ### iOS Tools (39)
193
+ ### iOS Tools (40)
194
194
 
195
195
  | Tool | Description |
196
196
  |------|-------------|
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 **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.
10
+ iOS Simulator, Android Emulator ve headless web kontrolü için **87 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ı (39)
192
+ ### iOS Araçları (40)
193
193
 
194
194
  | Araç | Açıklama |
195
195
  |------|-------------|
package/index.js CHANGED
@@ -30,7 +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
+ import { auditIosTree, auditAndroidDump, parseAuditResults } from "./tools/a11y/index.js";
34
34
  import { interactiveElements } from "./tools/ui-inspect/index.js";
35
35
  import { selectCrashReports } from "./tools/crash-logs/index.js";
36
36
  import {
@@ -361,6 +361,7 @@ const IOS_TOOLS = [
361
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"] } },
362
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
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: [] } },
364
+ { name: "ios_accessibility_audit_deep", description: "Run Apple's own accessibility audit (XCUIAccessibilityAudit) through an existing XCUITest and report its findings: contrast, Dynamic Type, clipped text, traits, hit regions, element descriptions. These cannot be reached from an accessibility tree dump, which is why this is a separate tool from ios_accessibility_audit and does not replace it. It is an xcodebuild test run, minutes rather than seconds, and the named test MUST itself call performAccessibilityAudit() - a test that does not is indistinguishable from an audit that found nothing, so the result reports whether the test ran and never invents a clean verdict.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to the .xcodeproj. Give this or workspace." }, workspace: { type: "string", description: "Path to the .xcworkspace." }, scheme: { type: "string" }, test_identifier: { type: "string", description: "e.g. AuditTest/testAccessibilityAudit(). Omit to read every test that ran." }, device_id: { type: "string", description: "Simulator udid. Defaults to the booted one." } }, required: ["scheme"] } },
364
365
  { 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)" } } } },
365
366
  ];
366
367
 
@@ -838,6 +839,34 @@ async function handleIOS(name, args, ctx = {}) {
838
839
  graph_saved_to: args.output_graph || null,
839
840
  }, null, 2);
840
841
  }
842
+ case "ios_accessibility_audit_deep": {
843
+ if (!args.project && !args.workspace) return "ERROR: pass project or workspace";
844
+ const container = args.workspace ? `-workspace ${shq(args.workspace)}` : `-project ${shq(args.project)}`;
845
+ const d = iosDevice(args.device_id);
846
+ const bundle = join(SCREENSHOT_DIR, `a11ydeep_${Date.now()}.xcresult`);
847
+ const only = args.test_identifier ? ` -only-testing:${shq(args.test_identifier)}` : "";
848
+ // xcodebuild exits non-zero when the audit finds anything, because each
849
+ // finding is an XCTest failure. That is a result, not a failure to run.
850
+ runCapture(
851
+ `xcodebuild test ${container} -scheme ${shq(args.scheme)} -destination ${shq(`platform=iOS Simulator,id=${d}`)} -resultBundlePath ${shq(bundle)}${only} 2>&1`,
852
+ { timeout: 900000 },
853
+ );
854
+ if (!existsSync(bundle)) {
855
+ return JSON.stringify({ measurable: false, reason: "xcodebuild produced no result bundle; the build failed before any test ran", test_ran: false, findings: [] }, null, 2);
856
+ }
857
+ const json = runCapture(`xcrun xcresulttool get test-results tests --path ${shq(bundle)} --format json 2>&1`, { timeout: 60000 });
858
+ const r = parseAuditResults(json, args.test_identifier || null);
859
+ return JSON.stringify({
860
+ measurable: r.measurable,
861
+ reason: r.reason,
862
+ test_ran: r.testRan,
863
+ test_result: r.testResult,
864
+ by_type: r.byType,
865
+ total_findings: r.measurable ? r.findings.length : null,
866
+ findings: r.findings,
867
+ result_bundle: bundle,
868
+ }, null, 2);
869
+ }
841
870
  case "ios_list_crashes": {
842
871
  const dir = join(homedir(), "Library", "Logs", "DiagnosticReports");
843
872
  if (!existsSync(dir)) return JSON.stringify({ dir, count: 0, reports: [] }, null, 2);
@@ -894,7 +923,7 @@ const ANDROID_TOOLS = [
894
923
  { name: "android_get_screen_size", description: "Get Android screen resolution", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
895
924
  { name: "android_open_url", description: "Open URL or deep link on Android", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
896
925
  { name: "android_clear_app_data", description: "Clear all data for Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
897
- { 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." } } } },
926
+ { name: "android_accessibility_audit", description: "Audit Android app accessibility on the connected device: missing contentDescription, clickable nodes TalkBack cannot focus, touch targets under 48dp, missing resource-ids, and whether the reading order follows the visual layout. Reports measurable:false with a reason rather than a clean result when the dump could not be read.", 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." }, rtl: { type: "boolean", description: "Expect right-to-left reading within a row. Default false." } } } },
898
927
  { 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"] } },
899
928
  { 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"] } },
900
929
  { 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"] } },
@@ -1055,13 +1084,14 @@ async function handleAndroid(name, args, ctx = {}) {
1055
1084
  run(`adb ${df} pull /sdcard/_mcp_a11y.xml ${shq(f)}`);
1056
1085
  run(`adb ${df} shell rm /sdcard/_mcp_a11y.xml`);
1057
1086
  const xml = existsSync(f) ? readFileSync(f, "utf-8") : "";
1058
- const r = auditAndroidDump({ xml, scope: args.scope || null });
1087
+ const r = auditAndroidDump({ xml, scope: args.scope || null, rtl: args.rtl === true });
1059
1088
  return JSON.stringify({
1060
1089
  scope: r.scope,
1061
1090
  measurable: r.measurable,
1062
1091
  reason: r.reason,
1063
1092
  elements_scanned: r.elementsScanned,
1064
1093
  elements_skipped: r.elementsSkipped,
1094
+ reading_order_ok: r.readingOrderOk ?? null,
1065
1095
  total_issues: r.totalIssues,
1066
1096
  critical: r.critical,
1067
1097
  important: r.important,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.5.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.",
3
+ "version": "3.7.0",
4
+ "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 87 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": {
@@ -250,7 +250,7 @@ export function auditIosTree({ tree, scope = null, rtl = false }) {
250
250
  * @param {string|null} [params.scope] - only audit resource-ids containing this
251
251
  * @returns {object} same shape as auditIosTree
252
252
  */
253
- export function auditAndroidDump({ xml, scope = null }) {
253
+ export function auditAndroidDump({ xml, scope = null, rtl = false }) {
254
254
  const text = typeof xml === "string" ? xml : "";
255
255
  if (!/<node\b/.test(text)) {
256
256
  return unmeasurable(scope, "the uiautomator dump held no nodes; the dump failed or the screen was not ready");
@@ -259,6 +259,7 @@ export function auditAndroidDump({ xml, scope = null }) {
259
259
  const issues = [];
260
260
  let elementsScanned = 0;
261
261
  let elementsSkipped = 0;
262
+ const sequence = [];
262
263
 
263
264
  for (const match of text.matchAll(/<node[^>]*>/g)) {
264
265
  const node = match[0];
@@ -276,13 +277,25 @@ export function auditAndroidDump({ xml, scope = null }) {
276
277
  }
277
278
  elementsScanned++;
278
279
 
280
+ // TalkBack moves between FOCUSABLE nodes. A clickable node that is not
281
+ // focusable can be tapped by a sighted user and never reached by a screen
282
+ // reader at all, which is a harder failure than a missing label: the
283
+ // control is invisible to the user rather than merely unnamed.
284
+ if (/focusable="false"/.test(node)) {
285
+ issues.push({ severity: "critical", issue: "Clickable but not focusable: TalkBack cannot reach this control", element: cls, resourceId: rid || null });
286
+ }
287
+
279
288
  if (!desc && !label) issues.push({ severity: "critical", issue: "Missing contentDescription", element: cls, resourceId: rid || null });
280
289
  if (!rid) issues.push({ severity: "warning", issue: "Missing resource-id (UI testing)", element: cls });
281
290
 
282
291
  const bounds = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
283
292
  if (bounds) {
284
- const w = parseInt(bounds[3], 10) - parseInt(bounds[1], 10);
285
- const h = parseInt(bounds[4], 10) - parseInt(bounds[2], 10);
293
+ const x = parseInt(bounds[1], 10);
294
+ const y = parseInt(bounds[2], 10);
295
+ const w = parseInt(bounds[3], 10) - x;
296
+ const h = parseInt(bounds[4], 10) - y;
297
+ // Same shape the iOS side uses, so one reading-order check serves both.
298
+ sequence.push({ role: cls, identifier: rid || null, title: desc || label || null, frame: { x, y, w, h } });
286
299
  if (w < ANDROID_MIN_TAP_DP || h < ANDROID_MIN_TAP_DP) {
287
300
  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 });
288
301
  }
@@ -297,12 +310,25 @@ export function auditAndroidDump({ xml, scope = null }) {
297
310
  );
298
311
  }
299
312
 
313
+ const order = checkReadingOrder(sequence, rtl);
314
+ if (!order.ok) {
315
+ const el = order.firstOutOfPlace;
316
+ issues.push({
317
+ severity: "important",
318
+ issue: `Reading order does not follow the visual layout: this element is read at position ${sequence.indexOf(el) + 1} but sits at position ${order.expectedIndex + 1} on screen`,
319
+ element: el.role,
320
+ resourceId: el.identifier || null,
321
+ label: el.title || null,
322
+ });
323
+ }
324
+
300
325
  return {
301
326
  measurable: true,
302
327
  reason: null,
303
328
  scope: scope || "all",
304
329
  elementsScanned,
305
330
  elementsSkipped,
331
+ readingOrderOk: order.ok,
306
332
  ...tally(issues),
307
333
  };
308
334
  }
@@ -333,3 +359,109 @@ function unmeasurable(scope, reason, elementsSkipped = 0) {
333
359
  issues: [],
334
360
  };
335
361
  }
362
+
363
+ // Apple's audit reports its findings as XCTest failure messages. These three
364
+ // were captured from a real run against a deliberately inaccessible app; the
365
+ // rest are classified by keyword and anything unrecognised keeps its raw text
366
+ // rather than being forced into a bucket it may not belong to.
367
+ const AUDIT_TYPE_PATTERNS = [
368
+ [/contrast/i, "contrast"],
369
+ [/has no description|description is|element description/i, "sufficientElementDescription"],
370
+ [/clipped|truncat/i, "textClipped"],
371
+ [/dynamic type|text size/i, "dynamicType"],
372
+ [/hit region|touch target|tap target/i, "hitRegion"],
373
+ [/trait/i, "trait"],
374
+ [/element detection|not detected/i, "elementDetection"],
375
+ [/parent|child/i, "parentChild"],
376
+ [/action/i, "action"],
377
+ ];
378
+
379
+ /**
380
+ * Classify one audit failure message.
381
+ *
382
+ * @param {string} message
383
+ * @returns {string} audit type, or "unclassified"
384
+ */
385
+ export function classifyAuditMessage(message) {
386
+ for (const [re, type] of AUDIT_TYPE_PATTERNS) if (re.test(message)) return type;
387
+ return "unclassified";
388
+ }
389
+
390
+ /**
391
+ * Pull accessibility audit findings out of `xcresulttool get test-results tests`.
392
+ *
393
+ * Shape captured from a real run: testNodes nest plan > bundle > suite > case,
394
+ * and a failing case carries children of nodeType "Failure Message" whose name
395
+ * is "<file>:<line>: <message>".
396
+ *
397
+ * The honest boundary, stated because it cannot be closed from here: a test that
398
+ * never calls performAccessibilityAudit passes, and a passing test is
399
+ * indistinguishable from an audit that found nothing. So the result reports
400
+ * whether the named test RAN, and the caller is told that the test must perform
401
+ * the audit itself. `measurable` is false when the test did not run at all.
402
+ *
403
+ * @param {object|string} results - parsed or raw JSON from xcresulttool
404
+ * @param {string|null} [testIdentifier] - e.g. "AuditTest/testAccessibilityAudit()"
405
+ * @returns {{measurable: boolean, reason: string|null, testRan: boolean,
406
+ * testResult: string|null, findings: object[], byType: object}}
407
+ */
408
+ export function parseAuditResults(results, testIdentifier = null) {
409
+ let doc = results;
410
+ if (typeof doc === "string") {
411
+ try {
412
+ doc = JSON.parse(doc);
413
+ } catch {
414
+ return { measurable: false, reason: "xcresulttool output was not JSON", testRan: false, testResult: null, findings: [], byType: {} };
415
+ }
416
+ }
417
+ if (!doc || !Array.isArray(doc.testNodes)) {
418
+ return { measurable: false, reason: "no testNodes in the result bundle; the build or the test run failed", testRan: false, testResult: null, findings: [], byType: {} };
419
+ }
420
+
421
+ const cases = [];
422
+ const walk = (n) => {
423
+ if (!n || typeof n !== "object") return;
424
+ if (n.nodeType === "Test Case") cases.push(n);
425
+ (n.children || []).forEach(walk);
426
+ };
427
+ doc.testNodes.forEach(walk);
428
+
429
+ const matched = testIdentifier
430
+ ? cases.filter((c) => c.nodeIdentifier === testIdentifier || c.name === testIdentifier)
431
+ : cases;
432
+
433
+ if (matched.length === 0) {
434
+ return {
435
+ measurable: false,
436
+ reason: testIdentifier
437
+ ? `no test case named "${testIdentifier}" ran; check the identifier and that the target built`
438
+ : "no test case ran",
439
+ testRan: false,
440
+ testResult: null,
441
+ findings: [],
442
+ byType: {},
443
+ };
444
+ }
445
+
446
+ const findings = [];
447
+ for (const c of matched) {
448
+ for (const child of c.children || []) {
449
+ if (child.nodeType !== "Failure Message") continue;
450
+ const raw = String(child.name || "");
451
+ const message = raw.replace(/^.*?:\d+:\s*/, "").trim();
452
+ findings.push({ test: c.nodeIdentifier || c.name || null, type: classifyAuditMessage(message), message, raw });
453
+ }
454
+ }
455
+
456
+ const byType = {};
457
+ for (const f of findings) byType[f.type] = (byType[f.type] || 0) + 1;
458
+
459
+ return {
460
+ measurable: true,
461
+ reason: null,
462
+ testRan: true,
463
+ testResult: matched[0].result || null,
464
+ findings,
465
+ byType,
466
+ };
467
+ }
@@ -86,7 +86,8 @@ const MEMINFO_ROWS = [
86
86
  * absolute number says almost nothing.
87
87
  *
88
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
89
+ * machine with adb installed but no device or emulator to run it against,
90
+ * unlike the leaks parser above, which was
90
91
  * checked against live output. The tests below pin the shape this expects; the
91
92
  * first real run is the measurement.
92
93
  *