mobile-debug-mcp 0.32.4 → 0.33.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.
Files changed (117) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.cursor-plugin/marketplace.json +16 -0
  4. package/.github/copilot-instructions.md +7 -0
  5. package/AGENTS.md +21 -10
  6. package/README.md +62 -4
  7. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  8. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  9. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  10. package/agent-plugin/.mcp.json +14 -0
  11. package/agent-plugin/README.md +5 -3
  12. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  13. package/agent-plugin/mcp.json +1 -1
  14. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +28 -0
  15. package/dist/interact/android.js +10 -4
  16. package/dist/interact/conditional-action.js +68 -0
  17. package/dist/interact/index.js +54 -14
  18. package/dist/interact/ios.js +14 -8
  19. package/dist/manage/android.js +4 -3
  20. package/dist/manage/ios.js +3 -2
  21. package/dist/observe/android.js +25 -4
  22. package/dist/observe/evidence.js +273 -0
  23. package/dist/observe/index.js +33 -6
  24. package/dist/observe/ios.js +25 -6
  25. package/dist/observe/readiness-service.js +115 -0
  26. package/dist/observe/readiness.js +81 -0
  27. package/dist/observe/recording.js +165 -0
  28. package/dist/observe/snapshot-metadata.js +10 -2
  29. package/dist/server/common.js +9 -0
  30. package/dist/server/condition-schema.js +32 -0
  31. package/dist/server/journey-schema.js +3 -2
  32. package/dist/server/readiness-schema.js +12 -0
  33. package/dist/server/tool-definitions.js +46 -0
  34. package/dist/server/tool-handlers.js +99 -17
  35. package/dist/server-core.js +1 -1
  36. package/dist/utils/ios/utils.js +4 -4
  37. package/dist/utils/operation-budget.js +24 -2
  38. package/dist/utils/resolve-device.js +7 -0
  39. package/docs/CHANGELOG.md +11 -0
  40. package/docs/specs/020-conditional-actions/intent.md +62 -0
  41. package/docs/specs/020-conditional-actions/plan.md +214 -0
  42. package/docs/specs/020-conditional-actions/spec.md +301 -0
  43. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  44. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  45. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  46. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  47. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  48. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  49. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  50. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  51. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  52. package/docs/specs/process.md +213 -0
  53. package/docs/tools/TOOLS.md +3 -0
  54. package/docs/tools/evidence.md +79 -0
  55. package/docs/tools/interact.md +125 -2
  56. package/docs/tools/observe.md +63 -0
  57. package/package.json +1 -1
  58. package/scripts/agent-plugin.mjs +68 -8
  59. package/src/interact/android.ts +10 -4
  60. package/src/interact/conditional-action.ts +75 -0
  61. package/src/interact/index.ts +42 -14
  62. package/src/interact/ios.ts +14 -8
  63. package/src/manage/android.ts +4 -3
  64. package/src/manage/ios.ts +3 -2
  65. package/src/observe/android.ts +33 -6
  66. package/src/observe/evidence.ts +255 -0
  67. package/src/observe/index.ts +32 -8
  68. package/src/observe/ios.ts +32 -8
  69. package/src/observe/readiness-service.ts +89 -0
  70. package/src/observe/readiness.ts +78 -0
  71. package/src/observe/snapshot-metadata.ts +12 -1
  72. package/src/server/common.ts +7 -0
  73. package/src/server/condition-schema.ts +32 -0
  74. package/src/server/journey-schema.ts +3 -2
  75. package/src/server/readiness-schema.ts +12 -0
  76. package/src/server/tool-definitions.ts +44 -0
  77. package/src/server/tool-handlers.ts +93 -17
  78. package/src/server-core.ts +1 -1
  79. package/src/types.ts +30 -1
  80. package/src/utils/ios/utils.ts +4 -4
  81. package/src/utils/operation-budget.ts +22 -2
  82. package/src/utils/resolve-device.ts +8 -0
  83. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  84. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  85. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  86. package/test/device/manual/interact/readiness.manual.ts +99 -0
  87. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  88. package/test/fixtures/readiness-app/README.md +12 -0
  89. package/test/fixtures/readiness-app/build.ts +37 -0
  90. package/test/fixtures/readiness-app/main.m +27 -0
  91. package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
  92. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  93. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  94. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  95. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  96. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  97. package/test/unit/observe/readiness.test.ts +39 -0
  98. package/test/unit/observe/readiness_native.test.ts +68 -0
  99. package/test/unit/server/conditional_actions.test.ts +299 -0
  100. package/test/unit/server/conditional_schema.test.ts +59 -0
  101. package/test/unit/server/correlated-evidence.test.ts +194 -0
  102. package/test/unit/server/readiness.test.ts +170 -0
  103. package/test/unit/server/selector_tap_target.test.ts +79 -0
  104. package/test/unit/server/tap_selector.test.ts +2 -0
  105. package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
  106. /package/{skills → .agents/skills}/README.md +0 -0
  107. /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
  108. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  109. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  110. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  111. /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
  112. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  113. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  114. /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
  115. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  116. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  117. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -1,3 +1,6 @@
1
+ import { currentReadinessWait, observeReadinessWait, isDeviceUnavailable } from '../observe/readiness.js';
2
+ import { readinessDelay } from '../observe/readiness-service.js';
3
+ import { isConditionBlocked } from './conditional-action.js';
1
4
  import { remainingBudget } from '../utils/operation-budget.js';
2
5
  import { createHash } from 'crypto';
3
6
  import { AndroidInteract } from './android.js';
@@ -419,6 +422,8 @@ export class ToolsInteract {
419
422
  }
420
423
  }
421
424
  static async _captureFingerprint(platform, deviceId) {
425
+ if (isConditionBlocked())
426
+ return null;
422
427
  try {
423
428
  const fingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
424
429
  return fingerprint?.fingerprint ?? null;
@@ -2068,7 +2073,8 @@ export class ToolsInteract {
2068
2073
  };
2069
2074
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution };
2070
2075
  }
2071
- static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }) {
2076
+ static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false, resolveTapTarget = false }) {
2077
+ const readinessTracker = currentReadinessWait();
2072
2078
  const overallStart = Date.now();
2073
2079
  const requestedIndex = typeof match?.index === 'number' ? match.index : null;
2074
2080
  const requested = {
@@ -2127,7 +2133,12 @@ export class ToolsInteract {
2127
2133
  remainingBudget();
2128
2134
  totalPollCount++;
2129
2135
  try {
2136
+ const acquisitionStarted = Date.now();
2130
2137
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
2138
+ observeReadinessWait(tree, acquisitionStarted);
2139
+ // A failed acquisition cannot establish that the requested tap target is absent.
2140
+ if (resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error)))
2141
+ throw new Error('ACTION_TIMEOUT');
2131
2142
  const elements = (tree && Array.isArray(tree.elements)) ? tree.elements : [];
2132
2143
  const matches = [];
2133
2144
  for (let i = 0; i < elements.length; i++) {
@@ -2213,7 +2224,8 @@ export class ToolsInteract {
2213
2224
  conditionMet = false;
2214
2225
  }
2215
2226
  else if (condition === 'clickable') {
2216
- matchedElement = chosen ? (ToolsInteract._resolveActionableAncestor(elements, chosen) || chosen) : null;
2227
+ matchedElement = chosen ? (resolveTapTarget && (!ToolsInteract._isVisibleElement(chosen.el) || chosen.el.enabled === false)
2228
+ ? chosen : ToolsInteract._resolveActionableAncestor(elements, chosen) || chosen) : null;
2217
2229
  if (matchedElement) {
2218
2230
  const b = matchedElement.el.bounds;
2219
2231
  const visibleFlag = !!matchedElement.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
@@ -2224,6 +2236,13 @@ export class ToolsInteract {
2224
2236
  else
2225
2237
  conditionMet = false;
2226
2238
  }
2239
+ // Waiting for a label to exist/be visible is distinct from resolving its
2240
+ // executable control. Reuse the clickable-wait ancestor policy without
2241
+ // promoting hidden or disabled labels into an unrelated enabled target.
2242
+ if (resolveTapTarget && conditionMet && matchedElement &&
2243
+ ToolsInteract._isVisibleElement(matchedElement.el) && matchedElement.el.enabled !== false) {
2244
+ matchedElement = ToolsInteract._resolveActionableAncestor(elements, matchedElement) ?? matchedElement;
2245
+ }
2227
2246
  const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
2228
2247
  const resolvedDeviceId = tree?.device?.id || deviceId;
2229
2248
  lastMatchedCount = matchedCount;
@@ -2236,7 +2255,7 @@ export class ToolsInteract {
2236
2255
  if (matchedAt === null)
2237
2256
  matchedAt = now;
2238
2257
  stableMatchCount++;
2239
- if (stableMatchCount >= stableObservationCount) {
2258
+ if (stableMatchCount >= stableObservationCount && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
2240
2259
  const latency_ms = now - overallStart;
2241
2260
  const outEl = lastMatchedElement;
2242
2261
  return {
@@ -2261,20 +2280,26 @@ export class ToolsInteract {
2261
2280
  }
2262
2281
  }
2263
2282
  catch (e) {
2283
+ // Deadline expiry does not invalidate the last successfully observed state.
2264
2284
  if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
2265
2285
  throw e;
2286
+ if (readinessTracker && !String(e).includes('READINESS_'))
2287
+ readinessTracker.unavailable();
2288
+ if (readinessTracker && isDeviceUnavailable(e))
2289
+ throw e;
2266
2290
  // Non-fatal per-poll error; record and continue
2267
- console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
2291
+ if (!readinessTracker)
2292
+ console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
2268
2293
  }
2269
2294
  if (singleAttempt)
2270
2295
  break;
2271
2296
  // Sleep until next poll
2272
- await new Promise(r => setTimeout(r, effectivePoll || 50));
2297
+ await (readinessTracker ? readinessDelay(effectivePoll || 50) : new Promise(r => setTimeout(r, effectivePoll || 50)));
2273
2298
  }
2274
2299
  // Attempt timed out; if more attempts allowed, backoff then retry
2275
2300
  if (attempts < maxAttempts) {
2276
2301
  if (backoff > 0)
2277
- await new Promise(r => setTimeout(r, backoff));
2302
+ await (readinessTracker ? readinessDelay(backoff) : new Promise(r => setTimeout(r, backoff)));
2278
2303
  continue;
2279
2304
  }
2280
2305
  // Final failure for this call
@@ -2290,7 +2315,7 @@ export class ToolsInteract {
2290
2315
  : ` observed ${lastMatchedCount} match(es)`;
2291
2316
  return {
2292
2317
  status: 'timeout',
2293
- error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
2318
+ error: { code: readinessTracker ? 'TIMEOUT' : 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
2294
2319
  metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
2295
2320
  requested,
2296
2321
  observed
@@ -2298,17 +2323,19 @@ export class ToolsInteract {
2298
2323
  }
2299
2324
  }
2300
2325
  catch (err) {
2301
- if (err instanceof Error && err.message === 'ACTION_TIMEOUT')
2326
+ if (!readinessTracker && err instanceof Error && err.message === 'ACTION_TIMEOUT')
2327
+ throw err;
2328
+ if (readinessTracker && isDeviceUnavailable(err))
2302
2329
  throw err;
2303
2330
  const elapsed = Date.now() - overallStart;
2304
2331
  return {
2305
2332
  status: 'timeout',
2306
- error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) },
2333
+ error: { code: readinessTracker && String(err).includes('ACTION_TIMEOUT') ? 'TIMEOUT' : 'INTERNAL_ERROR', message: readinessTracker ? 'Readiness predicate wait did not complete' : err instanceof Error ? err.message : String(err) },
2307
2334
  metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
2308
2335
  requested,
2309
2336
  observed: {
2310
2337
  matched_count: lastMatchedCount,
2311
- condition_satisfied: false,
2338
+ condition_satisfied: readinessTracker ? lastConditionSatisfied : false,
2312
2339
  selected_index: lastMatchedElement?.index ?? null,
2313
2340
  last_matched_element: lastMatchedElement
2314
2341
  }
@@ -2393,6 +2420,7 @@ export class ToolsInteract {
2393
2420
  };
2394
2421
  }
2395
2422
  static async waitForUIChangeHandler({ platform, deviceId, timeout_ms = 60000, stability_window_ms = 300, expected_change, scope = 'screen', target = null }) {
2423
+ const readinessTracker = currentReadinessWait();
2396
2424
  const start = Date.now();
2397
2425
  const pollIntervalMs = 300;
2398
2426
  const stabilityWindow = Math.max(0, typeof stability_window_ms === 'number' ? stability_window_ms : 300);
@@ -2415,7 +2443,11 @@ export class ToolsInteract {
2415
2443
  };
2416
2444
  while (Date.now() - start < timeout_ms) {
2417
2445
  try {
2446
+ if (readinessTracker)
2447
+ remainingBudget();
2448
+ const acquisitionStarted = Date.now();
2418
2449
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
2450
+ observeReadinessWait(tree, acquisitionStarted);
2419
2451
  const scopedTree = ToolsInteract._resolveUiChangeScope(tree, scope, target);
2420
2452
  if (scopedTree.error) {
2421
2453
  lastScopeResolution = scopedTree.resolution;
@@ -2461,7 +2493,7 @@ export class ToolsInteract {
2461
2493
  candidateSinceMs = Date.now();
2462
2494
  }
2463
2495
  const stableForMs = candidateSinceMs === null ? 0 : Date.now() - candidateSinceMs;
2464
- if (stabilityWindow === 0 || stableForMs >= stabilityWindow) {
2496
+ if ((stabilityWindow === 0 || stableForMs >= stabilityWindow) && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
2465
2497
  return {
2466
2498
  success: true,
2467
2499
  observed_change: candidateObservedChange ?? observedChange,
@@ -2486,10 +2518,18 @@ export class ToolsInteract {
2486
2518
  }
2487
2519
  }
2488
2520
  }
2489
- catch {
2490
- // Keep polling until timeout; the observable surface should be best-effort.
2521
+ catch (error) {
2522
+ if (readinessTracker && !String(error).includes('READINESS_'))
2523
+ readinessTracker.unavailable();
2524
+ if (readinessTracker && isDeviceUnavailable(error))
2525
+ throw error;
2526
+ if (readinessTracker && String(error).includes('ACTION_TIMEOUT')) {
2527
+ readinessTracker.deadline();
2528
+ break;
2529
+ }
2530
+ // Default waits retain best-effort polling.
2491
2531
  }
2492
- await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
2532
+ await (readinessTracker ? readinessDelay(pollIntervalMs) : new Promise(resolve => setTimeout(resolve, pollIntervalMs)));
2493
2533
  }
2494
2534
  return {
2495
2535
  success: false,
@@ -1,3 +1,5 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js';
2
+ import { beforeInputDispatch } from './conditional-action.js';
1
3
  import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js';
2
4
  import { spawn } from "child_process";
3
5
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
@@ -24,9 +26,11 @@ export class iOSInteract {
24
26
  if (targetUdid) {
25
27
  args.push('--udid', targetUdid);
26
28
  }
27
- await new Promise((resolve, reject) => {
28
- markDispatchStarted();
29
- const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
29
+ await beforeInputDispatch('ios', device.id);
30
+ const timeout = remainingBudget() || undefined;
31
+ markDispatchStarted();
32
+ await evidenceDispatch({ platform: 'ios', id: device.id }, () => new Promise((resolve, reject) => {
33
+ const proc = spawn(getIdbCmd(), args, { timeout });
30
34
  let stderr = '';
31
35
  proc.stderr.on('data', d => stderr += d.toString());
32
36
  proc.on('close', code => {
@@ -36,7 +40,7 @@ export class iOSInteract {
36
40
  reject(new Error(`idb ui tap failed: ${stderr}`));
37
41
  });
38
42
  proc.on('error', err => reject(err));
39
- });
43
+ }));
40
44
  return { device, success: true, x, y };
41
45
  }
42
46
  catch (e) {
@@ -64,9 +68,11 @@ export class iOSInteract {
64
68
  if (targetUdid) {
65
69
  args.push('--udid', targetUdid);
66
70
  }
67
- await new Promise((resolve, reject) => {
68
- markDispatchStarted();
69
- const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
71
+ await beforeInputDispatch('ios', device.id);
72
+ const timeout = remainingBudget() || undefined;
73
+ markDispatchStarted();
74
+ await evidenceDispatch({ platform: 'ios', id: device.id }, () => new Promise((resolve, reject) => {
75
+ const proc = spawn(getIdbCmd(), args, { timeout });
70
76
  let stderr = '';
71
77
  proc.stderr.on('data', d => stderr += d.toString());
72
78
  proc.on('close', code => {
@@ -76,7 +82,7 @@ export class iOSInteract {
76
82
  reject(new Error(`idb ui swipe failed: ${stderr}`));
77
83
  });
78
84
  proc.on('error', err => reject(err));
79
- });
85
+ }));
80
86
  return { device, success: true, start: [x1, y1], end: [x2, y2], duration };
81
87
  }
82
88
  catch (e) {
@@ -1,3 +1,4 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js';
1
2
  import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
2
3
  import { promises as fs } from 'fs';
3
4
  import { spawn } from 'child_process';
@@ -152,7 +153,7 @@ export class AndroidManage {
152
153
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
153
154
  try {
154
155
  markDispatchStarted();
155
- const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs });
156
+ const output = await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs }));
156
157
  const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null);
157
158
  return {
158
159
  device: deviceInfo,
@@ -178,7 +179,7 @@ export class AndroidManage {
178
179
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
179
180
  try {
180
181
  markDispatchStarted();
181
- await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
182
+ await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'am', 'force-stop', appId], deviceId));
182
183
  return { device: deviceInfo, appTerminated: true };
183
184
  }
184
185
  catch (e) {
@@ -211,7 +212,7 @@ export class AndroidManage {
211
212
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
212
213
  try {
213
214
  markDispatchStarted();
214
- const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
215
+ const output = await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'pm', 'clear', appId], deviceId));
215
216
  return { device: deviceInfo, dataCleared: output === 'Success' };
216
217
  }
217
218
  catch (e) {
@@ -1,3 +1,4 @@
1
+ import { evidenceDispatch } from '../observe/evidence.js';
1
2
  import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
2
3
  import { promises as fs } from "fs";
3
4
  import { spawn, spawnSync } from "child_process";
@@ -293,7 +294,7 @@ export class iOSManage {
293
294
  validateBundleId(bundleId);
294
295
  try {
295
296
  markDispatchStarted();
296
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
297
+ const result = await evidenceDispatch({ platform: 'ios', id: deviceId }, () => execCommand(['simctl', 'launch', deviceId, bundleId], deviceId));
297
298
  const device = await getIOSDeviceMetadata(deviceId);
298
299
  const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null;
299
300
  const pidMatch = result.output.match(/:\s*(\d+)\s*$/);
@@ -320,7 +321,7 @@ export class iOSManage {
320
321
  validateBundleId(bundleId);
321
322
  try {
322
323
  markDispatchStarted();
323
- await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
324
+ await evidenceDispatch({ platform: 'ios', id: deviceId }, () => execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId));
324
325
  const device = await getIOSDeviceMetadata(deviceId);
325
326
  return { device, appTerminated: true };
326
327
  }
@@ -1,3 +1,4 @@
1
+ import { captureEvidence } from './evidence.js';
1
2
  import { spawn } from "child_process";
2
3
  import { XMLParser } from "fast-xml-parser";
3
4
  import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "../utils/android/utils.js";
@@ -11,10 +12,14 @@ import { remainingBudget } from '../utils/operation-budget.js';
11
12
  import { createObservationMeasurement } from './performance-metrics.js';
12
13
  const activeLogStreams = new Map();
13
14
  export class AndroidObserve {
15
+ getLogStreamDevice(sessionId = 'default') { return activeLogStreams.get(sessionId)?.device ?? null; }
14
16
  async getDeviceMetadata(appId, deviceId) {
15
17
  return getAndroidDeviceMetadata(appId, deviceId);
16
18
  }
17
19
  async getUITree(deviceId) {
20
+ return captureEvidence('ui_tree', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId));
21
+ }
22
+ async collectGetUITree(deviceId) {
18
23
  const measurement = createObservationMeasurement('android');
19
24
  let metadata;
20
25
  try {
@@ -113,6 +118,9 @@ export class AndroidObserve {
113
118
  }
114
119
  }
115
120
  async getLogs(filters = {}) {
121
+ return captureEvidence('logs', { platform: 'android', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters));
122
+ }
123
+ async collectGetLogs(filters = {}) {
116
124
  const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
117
125
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
118
126
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
@@ -213,6 +221,9 @@ export class AndroidObserve {
213
221
  }
214
222
  }
215
223
  async captureScreen(deviceId) {
224
+ return captureEvidence('screenshot', { platform: 'android', id: deviceId ?? '' }, () => this.collectCaptureScreen(deviceId));
225
+ }
226
+ async collectCaptureScreen(deviceId) {
216
227
  const metadata = await getAndroidDeviceMetadata("", deviceId);
217
228
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
218
229
  return new Promise((resolve, reject) => {
@@ -331,6 +342,9 @@ export class AndroidObserve {
331
342
  });
332
343
  }
333
344
  async getCurrentScreen(deviceId) {
345
+ return captureEvidence('activity', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetCurrentScreen(deviceId));
346
+ }
347
+ async collectGetCurrentScreen(deviceId) {
334
348
  const metadata = await getAndroidDeviceMetadata("", deviceId);
335
349
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
336
350
  try {
@@ -385,12 +399,15 @@ export class AndroidObserve {
385
399
  }
386
400
  }
387
401
  async getScreenFingerprint(deviceId) {
402
+ return captureEvidence('fingerprint', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId));
403
+ }
404
+ async collectGetScreenFingerprint(deviceId) {
388
405
  try {
389
406
  const tree = await this.getUITree(deviceId);
390
407
  if (!tree || tree.error)
391
408
  return { fingerprint: null, error: tree.error };
392
409
  const current = await this.getCurrentScreen(deviceId).catch(() => null);
393
- return computeScreenFingerprint(tree, current, 'android', 50);
410
+ return { ...computeScreenFingerprint(tree, current, 'android', 50), captured_at_ms: tree.captured_at_ms };
394
411
  }
395
412
  catch (e) {
396
413
  return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
@@ -411,7 +428,7 @@ export class AndroidObserve {
411
428
  catch { }
412
429
  activeLogStreams.delete(sessionId);
413
430
  }
414
- const args = ['logcat', `--pid=${pid}`, filter];
431
+ const args = [...(deviceId ? ['-s', deviceId] : []), 'logcat', `--pid=${pid}`, filter];
415
432
  const proc = spawn(getAdbCmd(), args);
416
433
  const tmpDir = process.env.TMPDIR || '/tmp';
417
434
  const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
@@ -434,9 +451,10 @@ export class AndroidObserve {
434
451
  });
435
452
  proc.on('close', () => {
436
453
  stream.end();
437
- activeLogStreams.delete(sessionId);
454
+ if (activeLogStreams.get(sessionId)?.proc === proc)
455
+ activeLogStreams.delete(sessionId);
438
456
  });
439
- activeLogStreams.set(sessionId, { proc, file });
457
+ activeLogStreams.set(sessionId, { proc, file, device: { platform: 'android', id: deviceId ?? '' } });
440
458
  return { success: true, stream_started: true };
441
459
  }
442
460
  catch (e) {
@@ -455,6 +473,9 @@ export class AndroidObserve {
455
473
  return { success: true };
456
474
  }
457
475
  async readLogStream(sessionId = 'default', limit = 100, since) {
476
+ return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since));
477
+ }
478
+ async collectReadLogStream(sessionId = 'default', limit = 100, since) {
458
479
  const entry = activeLogStreams.get(sessionId);
459
480
  let file;
460
481
  if (entry && entry.file)
@@ -0,0 +1,273 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ const sessionId = randomUUID();
4
+ const context = new AsyncLocalStorage();
5
+ const history = new Map();
6
+ const active = new Map();
7
+ const orders = new Map();
8
+ const stamp = () => ({ wall: Date.now(), mono: performance.now() });
9
+ const deviceKey = (d) => `${d.platform}:${d.id}`;
10
+ const sameDevice = (a, b) => !!a && !!b && deviceKey(a) === deviceKey(b);
11
+ function validDevice(d) {
12
+ return d && ['android', 'ios'].includes(d.platform) && typeof d.id === 'string' && d.id && !['default', 'booted'].includes(d.id)
13
+ ? { platform: d.platform, id: d.id } : null;
14
+ }
15
+ function orderFor(device) {
16
+ if (!device)
17
+ return null;
18
+ const key = deviceKey(device);
19
+ let order = orders.get(key);
20
+ if (!order) {
21
+ order = { sequence: 0, active: 0, lastStarted: -Infinity };
22
+ orders.set(key, order);
23
+ if (orders.size > 1000) {
24
+ for (const [oldKey, oldOrder] of orders) {
25
+ if (oldKey !== key && oldOrder.active === 0)
26
+ orders.delete(oldKey);
27
+ if (orders.size <= 1000)
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ return order;
33
+ }
34
+ function prune() {
35
+ const now = performance.now();
36
+ for (const [key, record] of history) {
37
+ if (record.completed !== null && now - record.completed >= 600000)
38
+ history.delete(key);
39
+ }
40
+ while (history.size > 1000)
41
+ history.delete(history.keys().next().value);
42
+ }
43
+ export function validateActionRef(value) {
44
+ if (value === undefined)
45
+ return undefined;
46
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
47
+ Object.keys(value).some(key => !['sessionId', 'actionId'].includes(key)))
48
+ throw new Error('INVALID_ARGUMENT: invalid actionRef');
49
+ const ref = value;
50
+ for (const v of [ref.sessionId, ref.actionId])
51
+ if (typeof v !== 'string' || v.length < 1 || v.length > 128)
52
+ throw new Error('INVALID_ARGUMENT: invalid actionRef');
53
+ return { sessionId: ref.sessionId, actionId: ref.actionId };
54
+ }
55
+ export function currentEvidenceActionId() { return context.getStore()?.action?.id; }
56
+ export function bindEvidenceDevice(device) {
57
+ const current = context.getStore();
58
+ const target = validDevice(device);
59
+ if (!current || !target)
60
+ return;
61
+ current.device ??= target;
62
+ if (current.isAction && current.action)
63
+ current.action.device ??= target;
64
+ }
65
+ export function markEvidenceVerification() {
66
+ const current = context.getStore();
67
+ if (current) {
68
+ current.verification = stamp();
69
+ if (current.isAction && current.action)
70
+ current.action.verification = current.verification;
71
+ }
72
+ }
73
+ export async function evidenceDispatch(device, operation) {
74
+ const current = context.getStore();
75
+ if (!current?.isAction || !current.action)
76
+ return operation();
77
+ bindEvidenceDevice(device);
78
+ const record = current.action;
79
+ const start = stamp();
80
+ record.dispatchStart ??= start;
81
+ record.dispatchEnd = null;
82
+ const order = orderFor(validDevice(device));
83
+ if (order) {
84
+ order.sequence++;
85
+ order.active++;
86
+ order.lastStarted = start.mono;
87
+ }
88
+ try {
89
+ return await operation();
90
+ }
91
+ finally {
92
+ record.dispatchEnd = stamp();
93
+ if (order)
94
+ order.active--;
95
+ }
96
+ }
97
+ export function omitEvidence(kind) {
98
+ const current = context.getStore();
99
+ if (!current)
100
+ return;
101
+ const start = stamp();
102
+ current.observations.push({ value: {
103
+ evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
104
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
105
+ freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
106
+ }, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false });
107
+ }
108
+ export async function captureEvidence(kind, device, operation) {
109
+ const current = context.getStore();
110
+ if (!current)
111
+ return operation();
112
+ const target = validDevice(device);
113
+ if (target)
114
+ bindEvidenceDevice(target);
115
+ const order = orderFor(target);
116
+ const start = stamp();
117
+ const acquisition = { value: {
118
+ evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
119
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
120
+ freshness: 'unknown', freshness_reasons: [], device: target
121
+ }, start, end: null, order, startSequence: order?.sequence ?? 0,
122
+ endSequence: 0, overlapping: !!order?.active, reused: false };
123
+ current.observations.push(acquisition);
124
+ try {
125
+ const result = await operation();
126
+ const value = acquisition.value;
127
+ const resolved = validDevice(result?.device) ?? target;
128
+ value.device = resolved;
129
+ if (resolved)
130
+ bindEvidenceDevice(resolved);
131
+ const failure = result?.error ?? result?.meta?.error;
132
+ value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured';
133
+ if (failure)
134
+ value.error = failure;
135
+ const source = result?.captured_at_ms;
136
+ if (Number.isFinite(source) && source > 0) {
137
+ value.source_captured_at_ms = source;
138
+ acquisition.reused = source < start.wall;
139
+ }
140
+ if (kind === 'ui_tree') {
141
+ value.snapshot_session_id = result?.snapshot_session_id;
142
+ value.snapshot_revision = result?.snapshot_revision;
143
+ }
144
+ if (kind === 'logs') {
145
+ const entries = result?.logs ?? result?.entries ?? [];
146
+ const times = entries.map((e) => {
147
+ const t = e?.timestamp ?? e?._iso;
148
+ return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN;
149
+ }).filter((t) => Number.isFinite(t));
150
+ value.clock = 'device_or_unknown';
151
+ value.event_time_comparable = false;
152
+ value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null;
153
+ value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null;
154
+ }
155
+ return result;
156
+ }
157
+ catch (error) {
158
+ acquisition.value.error = error instanceof Error ? error.message : String(error);
159
+ throw error;
160
+ }
161
+ finally {
162
+ acquisition.end = stamp();
163
+ acquisition.value.capture_completed_at_ms = acquisition.end.wall;
164
+ acquisition.endSequence = order?.sequence ?? 0;
165
+ acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence;
166
+ if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
167
+ acquisition.value.source_captured_at_ms = acquisition.end.wall;
168
+ }
169
+ }
170
+ }
171
+ function association(current) {
172
+ if (current.isAction)
173
+ return { association: 'self', association_reason: null };
174
+ if (!current.ref)
175
+ return { association: 'none', association_reason: null };
176
+ const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
177
+ !sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null;
178
+ return { association: reason ? 'unavailable' : 'explicit', association_reason: reason };
179
+ }
180
+ function assemble(current) {
181
+ const end = stamp();
182
+ const link = association(current);
183
+ const action = link.association === 'unavailable' ? null : current.action;
184
+ const clockChanged = end.wall < current.start.wall;
185
+ const observations = current.observations.map(a => {
186
+ const v = { ...a.value, freshness_reasons: [] };
187
+ if (v.status === 'omitted')
188
+ return v;
189
+ const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null;
190
+ const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono;
191
+ const reason = (freshness, code) => { v.freshness = freshness; v.freshness_reasons.push(code); };
192
+ if (v.kind === 'logs')
193
+ reason('unknown', 'DEVICE_CLOCK_UNVERIFIED');
194
+ else if (clockChanged || (a.end && a.end.wall < a.start.wall))
195
+ reason('unknown', 'CLOCK_DISCONTINUITY');
196
+ else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall)
197
+ reason('unknown', 'CLOCK_DISCONTINUITY');
198
+ else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end)
199
+ reason('unknown', 'CAPTURE_TIME_UNKNOWN');
200
+ else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono)
201
+ reason('stale', 'BEFORE_ACTION');
202
+ else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted)))
203
+ reason('stale', 'SUPERSEDED_BY_ACTION');
204
+ else if (a.overlapping)
205
+ reason('unknown', 'OVERLAPPING_ACTION');
206
+ else if (a.reused)
207
+ reason('unknown', 'REUSED_OBSERVATION');
208
+ else {
209
+ v.freshness = 'fresh';
210
+ }
211
+ if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
212
+ v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms);
213
+ }
214
+ return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
215
+ action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association };
216
+ });
217
+ return {
218
+ session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
219
+ action_id: action?.id ?? null, device: current.device, ...link,
220
+ ...(current.ref ? { requested_action_ref: current.ref } : {}),
221
+ operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
222
+ dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
223
+ dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
224
+ verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
225
+ observations, trace_context: { supported: false, trace_id: null, markers: [] }
226
+ };
227
+ }
228
+ export async function withEvidence(options, operation) {
229
+ prune();
230
+ const parent = context.getStore();
231
+ const id = randomUUID();
232
+ const record = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
233
+ dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null;
234
+ const current = { correlationId: options.action ? record.correlationId : options.journey ? id : randomUUID(),
235
+ action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action };
236
+ if (options.action && record)
237
+ active.set(id, record);
238
+ if (parent && options.action)
239
+ parent.children.push(current);
240
+ try {
241
+ return await context.run(current, async () => {
242
+ const result = await operation();
243
+ current.end = stamp();
244
+ return { result, evidence: assemble(current) };
245
+ });
246
+ }
247
+ finally {
248
+ if (options.action && record) {
249
+ active.delete(id);
250
+ record.completed = performance.now();
251
+ history.set(id, record);
252
+ prune();
253
+ }
254
+ }
255
+ }
256
+ export function refreshJourneyEvidence(steps) {
257
+ const current = context.getStore();
258
+ if (!current)
259
+ return;
260
+ for (const step of steps) {
261
+ const previous = step.result?.evidence;
262
+ const child = current.children.find(c => c.action?.id === previous?.action_id);
263
+ if (child && step.result)
264
+ step.result.evidence = assemble(child);
265
+ }
266
+ }
267
+ export function completeJourneyVerification(actionId) {
268
+ const child = context.getStore()?.children.find(c => c.action?.id === actionId);
269
+ if (child?.action) {
270
+ child.verification = stamp();
271
+ child.action.verification = child.verification;
272
+ }
273
+ }