mobile-debug-mcp 0.36.0 → 0.37.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 (171) hide show
  1. package/README.md +10 -0
  2. package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
  8. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
  9. package/agent-plugin/mcp.json +1 -1
  10. package/agent-plugin/mcp_config.json +1 -1
  11. package/agent-plugin/plugin.json +1 -1
  12. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
  13. package/dist/contracts/device-readiness.js +1 -0
  14. package/dist/interact/waits/ui-change-wait.js +9 -2
  15. package/dist/interact/waits/ui-wait.js +7 -3
  16. package/dist/manage/index.js +3 -0
  17. package/dist/observe/device-readiness.js +116 -0
  18. package/dist/observe/evidence.js +31 -0
  19. package/dist/observe/ios-trace/sessions.js +5 -3
  20. package/dist/observe/readiness-service.js +21 -9
  21. package/dist/observe/readiness.js +30 -0
  22. package/dist/observe/screenshot-encoding.js +4 -0
  23. package/dist/observe/snapshot-collection.js +7 -2
  24. package/dist/server/definitions/manage.js +3 -2
  25. package/dist/server/handlers/action-execution.js +23 -14
  26. package/dist/server/handlers/compact-interaction.js +106 -0
  27. package/dist/server/handlers/compact-state.js +47 -0
  28. package/dist/server/handlers/input.js +2 -0
  29. package/dist/server/handlers/interact.js +2 -0
  30. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  31. package/dist/server/handlers/journey.js +18 -20
  32. package/dist/server/handlers/observe.js +2 -1
  33. package/dist/server/handlers/tool-handlers.js +9 -5
  34. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  35. package/dist/server/tool-definitions.js +9 -4
  36. package/dist/server-core.js +5 -2
  37. package/dist/server.js +1 -1
  38. package/dist/system/android.js +19 -15
  39. package/dist/system/index.js +2 -1
  40. package/dist/utils/android/utils.js +7 -16
  41. package/dist/utils/app-installation.js +77 -0
  42. package/dist/utils/benchmark-metrics.js +43 -0
  43. package/dist/utils/exec.js +4 -0
  44. package/dist/utils/ios/utils.js +4 -11
  45. package/dist/utils/resolve-device.js +3 -2
  46. package/dist/utils/timing.js +3 -3
  47. package/docs/CHANGELOG.md +25 -0
  48. package/docs/benchmarks/README.md +1 -0
  49. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  50. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  51. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  52. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  53. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  54. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  55. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  56. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  57. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  58. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  59. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  60. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  61. package/docs/specs/031-ios-system-traces/plan.md +28 -0
  62. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  63. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  64. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  65. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  66. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  67. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  68. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  69. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  70. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  71. package/docs/specs/034-app-installation-status/intent.md +41 -0
  72. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  73. package/docs/specs/034-app-installation-status/plan.md +73 -0
  74. package/docs/specs/034-app-installation-status/spec.md +114 -0
  75. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  76. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  77. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  78. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  79. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  80. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  81. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  82. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  83. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  84. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  85. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  86. package/docs/tools/TOOLS.md +1 -0
  87. package/docs/tools/compact-interactions.md +56 -0
  88. package/docs/tools/correlated-evidence.md +6 -0
  89. package/docs/tools/interact.md +17 -8
  90. package/docs/tools/manage.md +10 -2
  91. package/docs/tools/observe.md +42 -2
  92. package/docs/tools/system.md +6 -0
  93. package/package.json +1 -1
  94. package/src/contracts/device-readiness.ts +8 -0
  95. package/src/interact/waits/ui-change-wait.ts +7 -2
  96. package/src/interact/waits/ui-wait.ts +6 -3
  97. package/src/manage/index.ts +2 -0
  98. package/src/observe/device-readiness.ts +96 -0
  99. package/src/observe/evidence.ts +30 -1
  100. package/src/observe/ios-trace/sessions.ts +4 -2
  101. package/src/observe/readiness-service.ts +21 -9
  102. package/src/observe/readiness.ts +33 -0
  103. package/src/observe/screenshot-encoding.ts +4 -0
  104. package/src/observe/snapshot-collection.ts +7 -2
  105. package/src/server/common.ts +2 -2
  106. package/src/server/definitions/manage.ts +3 -2
  107. package/src/server/handlers/action-execution.ts +22 -14
  108. package/src/server/handlers/compact-interaction.ts +91 -0
  109. package/src/server/handlers/compact-state.ts +43 -0
  110. package/src/server/handlers/input.ts +2 -0
  111. package/src/server/handlers/interact.ts +2 -0
  112. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  113. package/src/server/handlers/journey.ts +15 -17
  114. package/src/server/handlers/observe.ts +2 -1
  115. package/src/server/handlers/payload.ts +2 -1
  116. package/src/server/handlers/tool-handlers.ts +9 -5
  117. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  118. package/src/server/tool-definitions.ts +8 -4
  119. package/src/server-core.ts +5 -2
  120. package/src/server.ts +1 -1
  121. package/src/system/android.ts +18 -11
  122. package/src/system/index.ts +2 -1
  123. package/src/types.ts +2 -0
  124. package/src/utils/android/utils.ts +8 -10
  125. package/src/utils/app-installation.ts +73 -0
  126. package/src/utils/benchmark-metrics.ts +42 -0
  127. package/src/utils/exec.ts +5 -0
  128. package/src/utils/ios/utils.ts +4 -10
  129. package/src/utils/resolve-device.ts +4 -2
  130. package/src/utils/timing.ts +3 -2
  131. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  132. package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
  133. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  134. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  135. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  136. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  137. package/test/device/automated/latency/end-to-end.ts +149 -0
  138. package/test/device/automated/latency/owned-transport.ts +81 -0
  139. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  140. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  141. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  142. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  143. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  144. package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
  145. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  146. package/test/fixtures/contracts/tool-definitions.json +51 -15
  147. package/test/fixtures/latency-journey-app/build.ts +3 -3
  148. package/test/unit/debugger/handle-guards.test.ts +2 -1
  149. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  150. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  151. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  152. package/test/unit/observe/device-readiness.test.ts +97 -0
  153. package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
  154. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  155. package/test/unit/server/compact-interaction.test.ts +87 -0
  156. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  157. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  158. package/test/unit/server/conditional_actions.test.ts +7 -3
  159. package/test/unit/server/correlated-evidence.test.ts +18 -6
  160. package/test/unit/server/end-to-end-report.test.ts +46 -0
  161. package/test/unit/server/input-device-readiness.test.ts +55 -0
  162. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  163. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  164. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  165. package/test/unit/server/readiness.test.ts +24 -1
  166. package/test/unit/server/run_journey.test.ts +2 -1
  167. package/test/unit/server/selector_tap_target.test.ts +2 -1
  168. package/test/unit/server/system-trace.test.ts +6 -4
  169. package/test/unit/utils/app-installation.test.ts +78 -0
  170. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  171. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -124,6 +124,29 @@ async function run() {
124
124
  assert.equal(result.completion_reason,'duration_limit');assert.ok(result.artifact)
125
125
  }finally{await f.cleanup()}
126
126
  }
127
+ const cancelledStopFailure=fixture()
128
+ try {
129
+ const started=await cancelledStopFailure.sessions.start(target,'fixture')
130
+ const directory=path.dirname(cancelledStopFailure.recordings[0].file)
131
+ cancelledStopFailure.flags({stop:true})
132
+ const failed=await cancelledStopFailure.sessions.control(started.trace_id,'cancel')
133
+ assert.equal(failed.cleanup.status,'failed')
134
+ await fs.stat(directory) // Never remove a directory while the recorder may still write.
135
+ cancelledStopFailure.flags({})
136
+ assert.equal((await cancelledStopFailure.sessions.control(started.trace_id,'cancel')).cleanup.status,'complete')
137
+ } finally {await cancelledStopFailure.cleanup()}
138
+ const exitedStopFailure=fixture()
139
+ try {
140
+ const started=await exitedStopFailure.sessions.start(target,'fixture')
141
+ const directory=path.dirname(exitedStopFailure.recordings[0].file)
142
+ exitedStopFailure.flags({stop:true})
143
+ assert.deepEqual(await exitedStopFailure.sessions.shutdown(),[], 'a stop failure after confirmed recorder exit must not strand discarded files')
144
+ const result=await exitedStopFailure.sessions.control(started.trace_id,'status')
145
+ assert.equal(result.state,'failed', 'cleanup must not hide the recorder failure')
146
+ assert.equal(result.reason,'ACTION_TIMEOUT')
147
+ assert.equal(result.cleanup.status,'complete')
148
+ await assert.rejects(()=>fs.stat(directory),{code:'ENOENT'})
149
+ } finally {await exitedStopFailure.cleanup()}
127
150
  const shutdown=fixture()
128
151
  try{const s=await shutdown.sessions.start(target,'fixture');assert.deepEqual(await shutdown.sessions.shutdown(),[]);assert.equal((await shutdown.sessions.control(s.trace_id,'status')).state,'cancelled');await assert.rejects(()=>shutdown.sessions.start(target,'fixture'),/TRACE_BUSY/)}finally{await shutdown.cleanup()}
129
152
  console.log('PASS iOS trace lifecycle, linkage, failure cleanup, retries and expiry')
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict'
2
+ import { ReadinessTracker } from '../../../src/observe/readiness.js'
3
+ import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
4
+ import { runWithBudget } from '../../../src/utils/operation-budget.js'
5
+
6
+ async function run() {
7
+ let power = 'Asleep', lock = 'true', fail = false
8
+ const tracker = new ReadinessTracker('android', 'fixture')
9
+ const observe = () => withDeviceReadinessProbe(async (_command, args) => {
10
+ if (fail) throw new Error('native failure')
11
+ return { exitCode: 0, stderr: '', stdout: args.includes('power')
12
+ ? (power ? `mWakefulness=${power}\nmWakefulnessChanging=false` : '')
13
+ : (lock ? `KeyguardServiceDelegate\n showing=${lock}\n mIsShowing=${lock}` : '') }
14
+ }, () => tracker.observeDevice())
15
+ await observe()
16
+ const blockedEvidence = structuredClone(tracker.deviceReadiness.evidence)
17
+ fail = true
18
+ await observe()
19
+ assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
20
+ assert.deepEqual(tracker.deviceReadiness.evidence, blockedEvidence, 'retained evidence keeps its original timestamps')
21
+ assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_POWER_OBSERVATION_FAILED'))
22
+ assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_LOCK_OBSERVATION_FAILED'))
23
+ assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_BLOCKER_EVIDENCE_RETAINED'))
24
+ await runWithBudget(0, observe)
25
+ tracker.deadline()
26
+ assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
27
+ assert.deepEqual(tracker.deviceReadiness.evidence, blockedEvidence)
28
+ fail = false; power = 'Awake'; lock = ''
29
+ await observe()
30
+ assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_LOCKED'], 'fresh awake evidence clears only the power blocker')
31
+ assert.equal(tracker.deviceReadiness.input_ready, false)
32
+ power = ''; lock = 'false'
33
+ await observe()
34
+ assert.deepEqual(tracker.deviceReadiness.blockers, [], 'fresh unlocked evidence clears the remaining lock blocker')
35
+ assert.equal(tracker.deviceReadiness.wakefulness, 'unknown', 'do not retain previously clear observations')
36
+ assert.equal(tracker.deviceReadiness.input_ready, null)
37
+ assert.ok(!tracker.deviceReadiness.limitations.includes('DEVICE_BLOCKER_EVIDENCE_RETAINED'))
38
+ console.log('PASS inconclusive/expired probes retain blockers per axis; only fresh clear signals remove them')
39
+ }
40
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert/strict'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { actionHandler } from '../../../src/server/handlers/action-execution.js'
4
+ import { toolHandlers, handleToolCall } from '../../../src/server/handlers/tool-handlers.js'
5
+ import { wrapResponse } from '../../../src/server/common.js'
6
+ import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
7
+ import { captureEvidence, evidenceDispatch } from '../../../src/observe/evidence.js'
8
+ import { markDispatchStarted } from '../../../src/utils/operation-budget.js'
9
+ import { observedState } from '../../../src/server/handlers/compact-state.js'
10
+ import { finalizeCompactInteraction } from '../../../src/server/handlers/compact-interaction.js'
11
+
12
+ const device = { platform: 'android' as const, id: 'fixture-emulator' }
13
+ const original = toolHandlers.tap
14
+ let dispatches = 0
15
+ const decode = (r: Awaited<ReturnType<typeof handleToolCall>>) => JSON.parse(r.content[0].text!)
16
+ const lookup = async (value: ReturnType<typeof decode>) => decode(await handleToolCall('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id }))
17
+ try {
18
+ const baselines = [1129, 1639, 1157, 1106]
19
+ for (const [index, kind] of ['success-none', 'success-observed', 'failed-dispatch', 'blocked-before-dispatch'].entries()) {
20
+ toolHandlers.tap = actionHandler('tap', async () => {
21
+ if (kind !== 'blocked-before-dispatch') await evidenceDispatch(device, async () => { markDispatchStarted(); dispatches++ })
22
+ if (kind === 'success-observed') await captureEvidence('fingerprint', device, async () => ({ device, fingerprint: 'screen-after' }))
23
+ return wrapResponse({ device, success: kind.startsWith('success'), lifecycle_state: kind === 'success-observed' ? 'verified' : 'pending_verification',
24
+ ...(kind === 'failed-dispatch' ? { failure_code: 'ACTION_TIMEOUT', retryable: false, details: { error: 'original timeout details' } } : {}),
25
+ ...(kind === 'blocked-before-dispatch' ? { failure_code: 'ELEMENT_NOT_FOUND', retryable: true } : {}) })
26
+ })
27
+ const response = await withDeviceReadinessProbe(async (_cmd, args) => ({ exitCode: 0, stderr: '', stdout: args.includes('power') ? 'mWakefulness=Awake\nmWakefulnessChanging=false' : 'KeyguardServiceDelegate\n showing=false' }), () => handleToolCall('tap', { platform: 'android', deviceId: device.id, responseMode: 'compact', verificationMode: index === 1 ? 'full' : 'none' }))
28
+ const result = decode(response), size = Buffer.byteLength(response.content[0].text!)
29
+ assert.ok(size <= 1280 + Buffer.byteLength(device.id), `${kind} exceeds budget: ${size}`)
30
+ assert.ok(size <= baselines[index] * .8, `${kind} needs 20% reduction: ${size}`)
31
+ assert.equal(result.observed_state.status, index === 1 ? 'observed' : 'unknown')
32
+ assert.equal(result.device_readiness.input_ready, null)
33
+ assert.equal(result.evidence.observations, undefined)
34
+ const before = dispatches, diagnostic = await lookup(result)
35
+ assert.equal(diagnostic.success, true)
36
+ assert.equal(dispatches, before, 'lookup must not replay input')
37
+ assert.equal(diagnostic.result.evidence.action_id, result.action_id)
38
+ if (index === 2) { assert.equal(result.delivery_status, 'unknown'); assert.equal(result.retryable, false); assert.equal(diagnostic.result.details.error, 'original timeout details') }
39
+ if (index === 3) assert.equal(result.dispatch_started, false)
40
+ console.log(`${kind}: ${size} bytes, ~${Math.ceil(size / 4)} tokens (bytes/4 estimate)`)
41
+ }
42
+ } finally { toolHandlers.tap = original }
43
+ const stamp = Date.now(), evidence = { session_id: randomUUID(), action_id: randomUUID(), correlation_id: randomUUID(), device, dispatch_completed_at_ms: stamp,
44
+ observations: [{ kind: 'ui_tree', status: 'captured', freshness: 'fresh', device, capture_started_at_ms: stamp + 1, source_captured_at_ms: stamp + 2, snapshot_session_id: randomUUID(), snapshot_revision: 4 }] }
45
+ assert.equal(observedState(evidence, true).status, 'observed')
46
+ for (const patch of [{ freshness: 'stale' }, { freshness: 'unknown' }, { source_captured_at_ms: null }, { status: 'failed' }, { device: { ...device, id: 'other' } }, { capture_started_at_ms: stamp - 1 }]) {
47
+ assert.equal(observedState({ ...evidence, observations: [{ ...evidence.observations[0], ...patch }] }, true).status, 'unknown')
48
+ }
49
+ const heavy = { success: false, failure_code: 'PRECONDITION_FAILED', dispatch_started: false, delivery_status: 'not_delivered', verification: { status: 'not_requested' }, evidence,
50
+ device_readiness: { wakefulness: 'asleep', lock_state: 'locked', input_ready: false, blockers: ['DEVICE_ASLEEP', 'DEVICE_LOCKED'], limitations: [] },
51
+ condition: { status: 'mismatch', reason: 'STATE_CHANGED', observed: { elements: Array(1000).fill({ text: '私'.repeat(100) }) } },
52
+ state_delta: { added: Array(1000).fill({ text: 'private' }), changed: [], removed: [], snapshot_revision: 4, truncated: true }, actual_state: '😀'.repeat(100) }
53
+ const response = finalizeCompactInteraction('tap', { responseMode: 'compact' }, wrapResponse(heavy)), compact = decode(response)
54
+ assert.ok(Buffer.byteLength(response.content[0].text!) <= 1280 + device.id.length)
55
+ assert.deepEqual(compact.device_readiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
56
+ assert.equal(compact.condition.observed?.elements, undefined)
57
+ assert.equal((await lookup(compact)).result.condition.observed.elements.length, 1000)
58
+ const debug = wrapResponse(heavy), unchanged = debug.content[0].text
59
+ finalizeCompactInteraction('tap', { responseMode: 'debug' }, debug)
60
+ assert.equal(debug.content[0].text, unchanged)
61
+ const steps = Array.from({ length: 50 }, (_, i) => ({ id: `${i}-😀`.repeat(100), type: 'tap', status: 'failed', success: false, result: heavy }))
62
+ const journeyResponse = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: false, stopped_at_step_id: steps[0].id, steps, evidence })), journey = decode(journeyResponse)
63
+ assert.equal(journey.steps.length, 50)
64
+ assert.deepEqual(journey.steps.map((s: { id: string }) => s.id), steps.map(s => s.id))
65
+ assert.ok(Buffer.byteLength(journeyResponse.content[0].text!) <= 1280 + 1280 * 50 + steps.reduce((n, s) => n + 2 * Buffer.byteLength(s.id), 0))
66
+ assert.equal(journey.diagnostics.available, false, 'oversized original journey reports unavailable explicitly')
67
+ console.log('PASS final compact budgets, freshness, no replay, diagnostic fidelity, huge conditions/deltas, multibyte values, 50-step IDs and debug compatibility')
68
+ const controlId = '\u0001'.repeat(3000)
69
+ const escaped = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: false, stopped_at_step_id: controlId,
70
+ steps: [{ id: controlId, type: 'assert', status: 'failed', success: false, result: { success: false, observed_state: { property: 'checked', value: false }, evidence } }], evidence }))
71
+ const escapedJourney = decode(escaped)
72
+ assert.equal(escapedJourney.steps[0].id, controlId)
73
+ assert.deepEqual(escapedJourney.steps[0].result.observed, { property: 'checked', value: false })
74
+ assert.ok(Buffer.byteLength(escaped.content[0].text!) <= 2560 + 2 * (Buffer.byteLength(JSON.stringify(controlId)) - 2) + 2 * device.id.length)
75
+ const maximal = { ...heavy, action_id: randomUUID(), action_type: 'scroll_to_element', lifecycle_state: 'pending_verification', retryable: false,
76
+ verification: { mode: 'light', status: 'unavailable' }, verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true },
77
+ device_readiness: { wakefulness: 'transitioning', lock_state: 'unknown', input_ready: false,
78
+ blockers: ['DEVICE_ASLEEP', 'DEVICE_LOCKED', 'DEVICE_TRANSITIONING', 'DEBUGGER_PAUSED'],
79
+ limitations: ['DEVICE_WAKEFULNESS_UNVERIFIED', 'DEVICE_LOCK_OBSERVATION_UNAVAILABLE', 'DEVICE_LOCK_OBSERVATION_FAILED'] },
80
+ evidence: { ...evidence, trace_context: { trace_id: randomUUID() } }, error: { code: 'UNKNOWN', message: '\u0001'.repeat(1000) } }
81
+ const maximalResponse = finalizeCompactInteraction('tap', { responseMode: 'compact' }, wrapResponse(maximal))
82
+ assert.ok(Buffer.byteLength(maximalResponse.content[0].text!) <= 1280 + device.id.length, maximalResponse.content[0].text!)
83
+ assert.deepEqual(decode(maximalResponse).device_readiness.blockers, maximal.device_readiness.blockers)
84
+ const maximalSteps = Array.from({ length: 50 }, (_, i) => ({ id: `max-${i}`, type: 'tap', status: 'failed', success: false, result: maximal }))
85
+ const maximalJourney = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: false, stopped_at_step_id: 'max-0', steps: maximalSteps, evidence }))
86
+ assert.ok(Buffer.byteLength(maximalJourney.content[0].text!) <= 1280 + 1280 * 50 + maximalSteps.reduce((n, s) => n + Buffer.byteLength(s.id), 0) + Buffer.byteLength('max-0') + 51 * device.id.length,
87
+ `maximal 50-step response exceeded budget: ${Buffer.byteLength(maximalJourney.content[0].text!)}`)
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict'
2
+ import { withEvidence, evidenceDispatch, captureEvidence, completeJourneyVerification, withJourneyStepEvidence, refreshJourneyEvidence } from '../../../src/observe/evidence.js'
3
+ import { observedState } from '../../../src/server/handlers/compact-state.js'
4
+ import { finalizeCompactInteraction } from '../../../src/server/handlers/compact-interaction.js'
5
+ import { wrapResponse } from '../../../src/server/common.js'
6
+ import { interactionDiagnostics } from '../../../src/server/handlers/interaction-diagnostics.js'
7
+
8
+ const device = { platform: 'android' as const, id: 'fixture' }
9
+ await withEvidence({ journey: true }, async () => {
10
+ const child = await withEvidence({ action: true }, async () => {
11
+ await evidenceDispatch(device, async () => {})
12
+ return { success: true, dispatch_started: true, verification: { mode: 'light', status: 'observed' } }
13
+ })
14
+ const assertion = await withJourneyStepEvidence(() => captureEvidence('ui_tree', device, async () => ({ success: true, device, snapshot_session_id: 'snapshot', snapshot_revision: 2 })))
15
+ completeJourneyVerification(child.evidence.action_id!)
16
+ const steps = [{ id: 'tap', type: 'tap', status: 'passed', success: true, result: { ...child.result, evidence: child.evidence } },
17
+ { id: 'assert', type: 'assert', status: 'passed', success: true, result: assertion }]
18
+ refreshJourneyEvidence(steps)
19
+ assert.equal(observedState(steps[0].result.evidence, true).status, 'observed', 'following assertion supplies actual fresh evidence')
20
+ const response = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: true, steps }))
21
+ const compact = JSON.parse(response.content[0].text!)
22
+ const ref = compact.steps[0].result.diagnostics
23
+ const retained = interactionDiagnostics.lookup(ref.session_id, ref.result_id)
24
+ assert.equal(retained.success, true)
25
+ assert.equal(compact.steps[0].result.observed_state.status, 'observed')
26
+ // A later action invalidates the first tap and assertion summaries at finalization.
27
+ await withEvidence({ action: true }, () => evidenceDispatch(device, async () => {}))
28
+ refreshJourneyEvidence(steps)
29
+ assert.equal(observedState(steps[0].result.evidence, true).status, 'unknown')
30
+ assert.equal(observedState(steps[1].result.evidence).status, 'unknown')
31
+ const final = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: true, steps }))
32
+ const result = JSON.parse(final.content[0].text!)
33
+ assert.equal(result.steps[0].result.observed_state.freshness, 'stale')
34
+ assert.equal(result.steps[1].result.observed_state.freshness, 'stale')
35
+ })
36
+ const error = { ...wrapResponse({ error: { code: 'TRACE_NOT_FOUND', message: 'Original failure' } }), isError: true }
37
+ finalizeCompactInteraction('run_journey', {}, error)
38
+ assert.equal(error.isError, true)
39
+ assert.equal(JSON.parse(error.content[0].text!).success, false)
40
+ console.log('PASS shared journey observation, final supersession, child diagnostics and MCP error signalling')
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict'
2
+ import { handleToolCall } from '../../../src/server-core.js'
3
+ import { ToolsInteract } from '../../../src/interact/index.js'
4
+ import { captureEvidence, withJourneyStepEvidence } from '../../../src/observe/evidence.js'
5
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
6
+ import { runWithBudget, remainingBudget } from '../../../src/utils/operation-budget.js'
7
+ import { finalizeCompactInteraction } from '../../../src/server/handlers/compact-interaction.js'
8
+ import { wrapResponse } from '../../../src/server/common.js'
9
+
10
+ const device = { platform: 'android' as const, id: 'fixture', model: 'Fixture', osVersion: '16', simulator: true }
11
+ const originalWait = ToolsInteract.waitForUIHandler
12
+ const decode = (response: Awaited<ReturnType<typeof handleToolCall>>) => JSON.parse(response.content[0].text!)
13
+ const diagnostics = async (value: ReturnType<typeof decode>) => {
14
+ const response = decode(await handleToolCall('get_interaction_diagnostics', {
15
+ sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id
16
+ }))
17
+ assert.equal(response.success, true)
18
+ return response.result
19
+ }
20
+ _setDeviceListersForTests({ listAndroidDevices: async () => [device] })
21
+ try {
22
+ for (const responseMode of ['compact', 'debug']) for (const type of ['assert', 'wait']) for (const thrown of [true, false]) {
23
+ let reads = 0
24
+ ToolsInteract.waitForUIHandler = async () => {
25
+ reads++
26
+ await captureEvidence('ui_tree', device, async () => ({ device, snapshot_session_id: 'captured-before-timeout', snapshot_revision: 7 }))
27
+ if (thrown) return runWithBudget(0, async () => { remainingBudget(); throw new Error('unreachable') })
28
+ return { status: 'timeout', error: { code: 'TIMEOUT', message: 'returned timeout' }, observed: { matched_count: 0 } }
29
+ }
30
+ const result = decode(await handleToolCall('run_journey', { platform: 'android', deviceId: device.id, responseMode, steps: [
31
+ { id: 'fails', type, ...(type === 'assert' ? { assertion: { kind: 'element_visible', selector: { text: 'Ready' } } } : { selector: { text: 'Ready' } }) },
32
+ { id: 'later', type: 'wait', selector: { text: 'Later' } }
33
+ ] }))
34
+ assert.equal(result.success, false)
35
+ assert.equal(result.stopped_at_step_id, 'fails')
36
+ assert.equal(result.steps[1].status, 'not_run')
37
+ assert.equal(reads, 1, 'neither a failure nor diagnostics lookup replays the observation')
38
+ const step = responseMode === 'compact' ? await diagnostics(result.steps[0].result) : result.steps[0].result
39
+ if (thrown) {
40
+ assert.equal(step.failure_code, type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED')
41
+ assert.equal(step.error, 'ACTION_TIMEOUT')
42
+ } else if (type === 'assert') {
43
+ assert.equal(step.failure_code, 'ASSERTION_TIMEOUT')
44
+ assert.equal(step.reason, 'returned timeout')
45
+ assert.equal(step.observed.matched_count, 0)
46
+ } else {
47
+ assert.equal(step.status, 'timeout')
48
+ assert.deepEqual(step.error, { code: 'TIMEOUT', message: 'returned timeout' })
49
+ }
50
+ if (responseMode === 'compact') {
51
+ const observation = step.evidence.observations[0]
52
+ assert.equal(observation.kind, 'ui_tree')
53
+ assert.equal(observation.status, 'captured')
54
+ assert.equal(observation.snapshot_revision, 7)
55
+ assert.equal(observation.snapshot_session_id, 'captured-before-timeout')
56
+ assert.deepEqual(observation.device, { platform: device.platform, id: device.id })
57
+ const journey = await diagnostics(result)
58
+ assert.deepEqual(journey.steps[0].result.evidence, step.evidence, 'journey and child diagnostics retain the same final evidence')
59
+ } else assert.equal(step.evidence, undefined, 'debug step shape is unchanged')
60
+ assert.equal(reads, 1)
61
+ }
62
+ } finally {
63
+ ToolsInteract.waitForUIHandler = originalWait
64
+ _resetDeviceListersForTests()
65
+ }
66
+ const originalError = new Error('outside evidence context')
67
+ await assert.rejects(withJourneyStepEvidence(async () => { throw originalError }), error => error === originalError)
68
+ for (const message of ['short error', 'a'.repeat(128), 'a'.repeat(129), '😀'.repeat(33)]) {
69
+ const response = finalizeCompactInteraction('run_journey', {}, wrapResponse({ error: { code: 'FIXTURE_ERROR', message } }))
70
+ const result = decode(response)
71
+ const shortened = Buffer.byteLength(message) > 128
72
+ assert.equal(result.truncated, shortened ? true : undefined)
73
+ assert.equal(result.error.code, 'FIXTURE_ERROR')
74
+ assert.ok(Buffer.byteLength(result.error.message) <= 128)
75
+ assert.ok(Buffer.byteLength(response.content[0].text!) < 1280, 'truncation must be marked even without overall budget pressure')
76
+ assert.equal((await diagnostics(result)).error.message, message)
77
+ }
78
+ console.log('PASS thrown journey evidence, returned-timeout/debug compatibility, and ASCII/multibyte journey error truncation')
@@ -70,6 +70,9 @@ else process.exit(1)
70
70
  ToolsNetwork.notifyActionStart = () => { networkStarts++ }
71
71
  _setDeviceListersForTests({ listAndroidDevices: async () => [device()], listIOSDevices: async () => [device()] })
72
72
  const payload = (response: any) => JSON.parse(response.content[0].text)
73
+ const retained = async (result: any) => result.diagnostics
74
+ ? payload(await handleToolCall('get_interaction_diagnostics', { sessionId: result.diagnostics.session_id, resultId: result.diagnostics.result_id })).result
75
+ : result
73
76
  const nativeInputs = () => readFileSync(eventsPath, 'utf8').split('\n').filter(line => /shell input|ui tap|ui swipe/.test(line))
74
77
  const baseline = () => {
75
78
  uiState = 'Ready'; propertyChange = {}; observationMode = 'normal'; readDelay = 0
@@ -129,7 +132,8 @@ else process.exit(1)
129
132
  assert.equal(result.retryable, false)
130
133
  assert.equal(result.delivery_status, 'not_delivered')
131
134
  assert.equal(result.verification.status, 'not_requested')
132
- assert.deepEqual(result.condition.observed.elements[0], tree().elements[0])
135
+ assert.deepEqual((await retained(result)).condition.observed.elements[0], tree().elements[0])
136
+ if (responseMode === 'compact') assert.equal(result.observed_state.status, 'unknown')
133
137
  assert.equal(reads, 1, 'no post-dispatch tree read after guard failure')
134
138
  assert.equal(nativeInputs().length, inputs)
135
139
  assert.equal(networkStarts, starts)
@@ -239,7 +243,7 @@ else process.exit(1)
239
243
  assert.equal(journey.stopped_at_step_id, 'guarded')
240
244
  assert.equal(journey.steps[0].status, 'passed')
241
245
  assert.equal(journey.steps[1].result.failure_code, 'PRECONDITION_FAILED')
242
- assert.equal(journey.steps[1].result.condition.observed.elements[0].text, 'Changed')
246
+ assert.equal((await retained(journey.steps[1].result)).condition.observed.elements[0].text, 'Changed')
243
247
  assert.equal(journey.steps[1].result.verification.status, 'not_requested')
244
248
  assert.equal(journey.steps[2].status, 'not_run')
245
249
  assert.equal(nativeInputs().length, before)
@@ -268,7 +272,7 @@ else process.exit(1)
268
272
  const before = nativeInputs().length
269
273
  const mismatch = await call('tap', { expectedState })
270
274
  assert.equal(mismatch.failure_code, 'PRECONDITION_FAILED', JSON.stringify(mismatch))
271
- assert.equal(mismatch.condition.observed.elements[0].text, 'Changed through native hierarchy')
275
+ assert.equal((await retained(mismatch)).condition.observed.elements[0].text, 'Changed through native hierarchy')
272
276
  assert.equal(nativeInputs().length, before)
273
277
  const compact: any = await ToolsObserve.getUITreeHandler({ platform, deviceId: 'fixture', responseMode: 'compact' })
274
278
  assert.equal(compact.snapshot_session_id, expectedState.snapshotSessionId)
@@ -59,7 +59,15 @@ else process.exit(1)
59
59
  if (platform === 'android') replace('collectGetCurrentScreen', async (id: string) => ({ device: device(platform, id), activity: 'FixtureActivity', package: 'fixture.app' }))
60
60
  }
61
61
  const payload = (r: any) => JSON.parse(r.content[0].text)
62
- const call = async (name: string, args: Record<string, unknown>) => payload(await handleToolCall(name, args))
62
+ const call = async (name: string, args: Record<string, unknown>) => {
63
+ const value = payload(await handleToolCall(name, args))
64
+ if (name !== 'run_journey' && value.diagnostics?.available) {
65
+ const retained = payload(await handleToolCall('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id }))
66
+ assert.equal(retained.success, true)
67
+ return { ...value, evidence: retained.result.evidence }
68
+ }
69
+ return value
70
+ }
63
71
  const refFor = (value: any) => ({ sessionId: value.evidence.session_id, actionId: value.evidence.action_id })
64
72
  try {
65
73
  for (const platform of ['android', 'ios'] as const) {
@@ -122,9 +130,11 @@ else process.exit(1)
122
130
  verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 500 })
123
131
  delete process.env.EVIDENCE_INPUT_SLOW
124
132
  assert.equal(timeout.failure_code, 'ACTION_TIMEOUT')
125
- assert.ok(timeout.evidence.dispatch_started_at_ms)
126
- assert.ok(timeout.evidence.dispatch_completed_at_ms)
127
- assert.equal(timeout.evidence.verification_completed_at_ms, null)
133
+ const retainedTimeout = await call('get_interaction_diagnostics', { sessionId: timeout.diagnostics.session_id, resultId: timeout.diagnostics.result_id })
134
+ assert.equal(retainedTimeout.success, true)
135
+ assert.ok(retainedTimeout.result.evidence.dispatch_started_at_ms)
136
+ assert.ok(retainedTimeout.result.evidence.dispatch_completed_at_ms)
137
+ assert.equal(retainedTimeout.result.evidence.verification_completed_at_ms, null)
128
138
  const before = captureCount
129
139
  const malformed = await call('capture_screenshot', { platform: 'android', actionRef: { actionId: '' } })
130
140
  assert.match(malformed.error.message, /INVALID_ARGUMENT/)
@@ -149,9 +159,11 @@ else process.exit(1)
149
159
  ] })
150
160
  assert.equal(light.success, true)
151
161
  const lightStep = light.steps[0].result
152
- assert.ok(lightStep.evidence.verification_completed_at_ms)
162
+ assert.equal(lightStep.observed_state.status, 'observed', JSON.stringify(lightStep))
163
+ const retainedLight = await call('get_interaction_diagnostics', { sessionId: lightStep.diagnostics.session_id, resultId: lightStep.diagnostics.result_id })
164
+ assert.ok(retainedLight.result.evidence.verification_completed_at_ms)
153
165
  const afterLight = await call('capture_screenshot', { platform: 'android', deviceId: 'fixture-android', actionRef: refFor(lightStep) })
154
- assert.equal(afterLight.evidence.verification_completed_at_ms, lightStep.evidence.verification_completed_at_ms)
166
+ assert.equal(afterLight.evidence.verification_completed_at_ms, retainedLight.result.evidence.verification_completed_at_ms)
155
167
 
156
168
  const started = await call('start_log_stream', { platform: 'android', deviceId: 'other', sessionId: 'evidence-owned', packageName: 'fixture.app' })
157
169
  assert.equal(started.success, true)
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { parseMetric, summarizeAttempts, unionDuration, observationOverhead, type Attempt } from '../../device/automated/latency/end-to-end-report.js'
3
+ import { validObservation } from '../../device/automated/latency/end-to-end-session.js'
4
+
5
+ assert.equal(unionDuration([{ stage: 'parent', start_ms: 1, end_ms: 10 }, { stage: 'nested', start_ms: 2, end_ms: 5 },
6
+ { stage: 'overlap', start_ms: 8, end_ms: 12 }], 11), 10)
7
+ const attempts: Attempt[] = [
8
+ { case: 'full', iteration: 0, warmup: true, success: false, setup_ms: 1, requests: [] },
9
+ { case: 'full', iteration: 1, warmup: false, success: false, setup_ms: 1, requests: [] },
10
+ { case: 'full', iteration: 2, warmup: false, success: true, setup_ms: 500, requests: [
11
+ { tool: 'run_journey', elapsed_ms: 100, response_bytes: 50, success: true, tree: [], metrics: [
12
+ { event: 'latency_metric', request_id: 'opaque', tool: 'run_journey', handler_completed: true, elapsed_ms: 90,
13
+ spans: [{ stage: 'parent', start_ms: 0, end_ms: 80 }, { stage: 'child', start_ms: 10, end_ms: 70 }] }
14
+ ] }
15
+ ] },
16
+ { case: 'empty', iteration: 1, warmup: false, success: false, setup_ms: 0, requests: [] }
17
+ ]
18
+ const report = summarizeAttempts(attempts)
19
+ assert.equal(report.full.total_ms?.p50, 100)
20
+ assert.equal(report.full.failures, 1)
21
+ assert.equal(report.full.warmup_failures, 1)
22
+ assert.equal(report.full.handler_unattributed_ms?.p50, 10)
23
+ assert.equal(report.full.client_minus_handler_ms?.p50, 10)
24
+ assert.equal(report.empty.total_ms, null)
25
+ assert.equal(observationOverhead(attempts).tree.paired_on_minus_off_ms, null)
26
+ const observation = (name: string, elapsed: number, success = true): Attempt => ({ case: name, iteration: 3, warmup: false,
27
+ success, setup_ms: 0, requests: [{ tool: 'get_ui_tree', elapsed_ms: elapsed, success, metrics: [], tree: [] }] })
28
+ assert.equal(observationOverhead([observation('tree-on', 15), observation('tree-off', 20)]).tree.paired_on_minus_off_ms?.p50, -5)
29
+ assert.equal(observationOverhead([observation('tree-on', 15), observation('tree-off', 20, false)]).tree.paired_on_minus_off_ms, null)
30
+ assert.equal(parseMetric('secret stderr'), null)
31
+ assert.equal(parseMetric('{"event":"latency_metric","tool":"tap","spans":[]}'), null)
32
+ assert.equal(parseMetric('{"event":"unrecognized","secret":"credential"}'), null)
33
+ const filtered = parseMetric(JSON.stringify({ event: 'latency_metric', tool: 'tap', request_id: 'opaque', handler_completed: true,
34
+ elapsed_ms: 10, arguments: { deviceId: 'private-device', text: 'secret' }, stdout: 'secret',
35
+ spans: [{ stage: 'dispatch_ms', start_ms: 0, end_ms: 8, secret: 'credential' }] }))
36
+ assert.ok(filtered)
37
+ assert.ok(!JSON.stringify(filtered).includes('secret'))
38
+ assert.ok(!JSON.stringify(filtered).includes('private-device'))
39
+ const tree = { device: { id: 'emulator-fixture' }, elements: [{}] }
40
+ assert.equal(validObservation('get_ui_tree', tree, [], 'emulator-fixture'), true)
41
+ assert.equal(validObservation('get_ui_tree', tree, [], 'other-device'), false)
42
+ assert.equal(validObservation('get_ui_tree', {}, [], 'emulator-fixture'), false)
43
+ assert.equal(validObservation('capture_screenshot', { device: { id: 'emulator-fixture' } }, [], 'emulator-fixture'), false)
44
+ assert.equal(validObservation('capture_debug_snapshot', { raw: { ui_tree: tree, fingerprint_error: 'failed' } }, [], 'emulator-fixture'), false)
45
+ assert.equal(validObservation('capture_debug_snapshot', { raw: { ui_tree: tree } }, [], 'emulator-fixture'), true)
46
+ console.log('End-to-end benchmark accounting/privacy: PASS')
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict'
2
+ import { handleToolCall } from '../../../src/server-core.js'
3
+ import { AndroidInteract } from '../../../src/interact/android.js'
4
+ import { _setDeviceListersForTests, _resetDeviceListersForTests, withResolvedDevice } from '../../../src/utils/resolve-device.js'
5
+ import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
6
+
7
+ async function run() {
8
+ const physical = { platform: 'android', id: 'physical', model: 'Fixture', osVersion: '16', simulator: false }
9
+ const emulator = { ...physical, id: 'emulator', simulator: true }
10
+ const originalTap = AndroidInteract.prototype.tap, originalSwipe = AndroidInteract.prototype.swipe
11
+ let dispatched = '', dispatches = 0, resolutions = 0
12
+ _setDeviceListersForTests({ listAndroidDevices: async () => { resolutions++; return [emulator, physical] } })
13
+ AndroidInteract.prototype.tap = async (x, y, id) => {
14
+ dispatched = id!; dispatches++
15
+ return { success: true, device: id === physical.id ? physical : emulator, x, y }
16
+ }
17
+ AndroidInteract.prototype.swipe = async (x1, y1, x2, y2, duration, id) => {
18
+ dispatched = id!; dispatches++
19
+ return { success: true, device: id === physical.id ? physical : emulator, start: [x1, y1], end: [x2, y2], duration }
20
+ }
21
+ try {
22
+ for (const tool of ['tap', 'swipe']) {
23
+ for (const responseMode of ['compact', 'debug']) {
24
+ for (const explicit of [false, true]) {
25
+ const expected = explicit ? emulator.id : physical.id
26
+ const probes: string[] = []
27
+ const response = await withDeviceReadinessProbe(async (_cmd, args) => {
28
+ probes.push(args[1])
29
+ return { exitCode: 0, stderr: '', stdout: args.includes('power') ? 'mWakefulness=Asleep\nmWakefulnessChanging=false' : 'KeyguardServiceDelegate\n showing=false\n mIsShowing=false' }
30
+ }, () => handleToolCall(tool, { platform: 'android', ...(explicit ? { deviceId: emulator.id } : {}), x: 1, y: 2, x1: 1, y1: 2, x2: 3, y2: 4, duration: 100, verificationMode: 'none', responseMode }))
31
+ const result = JSON.parse(response.content[0].text!)
32
+ assert.equal(result.success, true)
33
+ assert.equal(dispatched, expected)
34
+ assert.deepEqual(probes, [expected, expected], 'readiness probes use the device actually selected for dispatch')
35
+ assert.deepEqual(result.device_readiness.blockers, ['DEVICE_ASLEEP'])
36
+ }
37
+ }
38
+ }
39
+ assert.equal(dispatches, 8)
40
+ assert.equal(resolutions, 8, 'readiness does not select another device')
41
+ const before = resolutions
42
+ await withResolvedDevice(emulator, () => withDeviceReadinessProbe(async (_cmd, args) => {
43
+ assert.equal(args[1], emulator.id)
44
+ return { exitCode: 0, stderr: '', stdout: '' }
45
+ }, () => handleToolCall('tap', { x: 1, y: 2, verificationMode: 'none', responseMode: 'compact' })))
46
+ assert.equal(dispatched, emulator.id, 'an existing resolved/guarded target is preserved')
47
+ assert.equal(resolutions, before)
48
+ console.log('PASS ordinary tap/swipe device readiness follows implicit, explicit and already-resolved targets')
49
+ } finally {
50
+ AndroidInteract.prototype.tap = originalTap
51
+ AndroidInteract.prototype.swipe = originalSwipe
52
+ _resetDeviceListersForTests()
53
+ }
54
+ }
55
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { InteractionDiagnostics } from '../../../src/server/handlers/interaction-diagnostics.js'
4
+ import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
5
+
6
+ let now = 0
7
+ const store = new InteractionDiagnostics(() => now)
8
+ const value = { error: 'original private diagnostic', evidence: { observations: [1, 2] } }
9
+ const ref = store.retain(value)
10
+ assert.equal(ref.available, true)
11
+ assert.deepEqual(store.lookup(ref.session_id, ref.result_id), { success: true, result: value })
12
+ value.error = 'mutated'
13
+ assert.notDeepEqual(store.lookup(ref.session_id, ref.result_id), { success: true, result: value })
14
+ now = 599999
15
+ assert.equal(store.lookup(ref.session_id, ref.result_id).success, true)
16
+ now++
17
+ assert.equal(store.lookup(ref.session_id, ref.result_id).error?.code, 'RESULT_UNAVAILABLE', 'reads do not extend expiry')
18
+ assert.equal(store.lookup(randomUUID(), ref.result_id).error?.code, 'SESSION_MISMATCH')
19
+ const oldest = store.retain({ n: 0 })
20
+ for (let n = 1; n <= 100; n++) store.retain({ n })
21
+ assert.equal(store.lookup(oldest.session_id, oldest.result_id).success, false)
22
+ const large = store.retain('a'.repeat(1024 * 1024))
23
+ assert.equal(large.available, false)
24
+ assert.equal(large.reason, 'RESULT_TOO_LARGE')
25
+ const byteStore = new InteractionDiagnostics()
26
+ const first = byteStore.retain('a'.repeat(1024 * 1024 - 100))
27
+ for (let n = 0; n < 16; n++) byteStore.retain('a'.repeat(1024 * 1024 - 100))
28
+ assert.equal(byteStore.lookup(first.session_id, first.result_id).success, false, 'total byte limit evicts before count limit')
29
+ const refs = await Promise.all(Array.from({ length: 30 }, async (_, n) => store.retain({ n })))
30
+ assert.equal(new Set(refs.map(r => r.result_id)).size, 30)
31
+ assert.ok(toolDefinitions.find(t => t.name === 'get_interaction_diagnostics')?.inputSchema.additionalProperties === false)
32
+ for (const args of [{}, { sessionId: randomUUID(), resultId: randomUUID(), extra: true }, { sessionId: 'bad', resultId: randomUUID() }]) {
33
+ const response = await handleToolCall('get_interaction_diagnostics', args)
34
+ assert.equal(JSON.parse(response.content[0].text!).error.code, 'INVALID_ARGUMENT')
35
+ }
36
+ console.log('PASS diagnostics fidelity, expiry, count/byte bounds, size rejection, session isolation and strict dispatch schema')
37
+
38
+ let expire: (() => void) | undefined
39
+ let cancelled = 0
40
+ const automatic = new InteractionDiagnostics(() => 0, randomUUID(), callback => { expire = callback; return () => { cancelled++ } })
41
+ const automaticRef = automatic.retain({ original: true })
42
+ assert(expire)
43
+ expire()
44
+ assert.equal(automatic.lookup(automaticRef.session_id, automaticRef.result_id).success, false, 'timer purges the payload without later cache access')
45
+ assert.equal(cancelled, 1)
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
4
+ import { OwnedTransport } from '../../device/automated/latency/owned-transport.js'
5
+
6
+ if (process.platform !== 'win32') {
7
+ // A stalled initialization with a stubborn child must time out, then both
8
+ // processes must be gone. This uses real processes, not mocked kill calls.
9
+ let childStarted = false
10
+ const transport = new OwnedTransport(process.env, chunk => { childStarted ||= chunk.includes('ready') }, ['-e', `
11
+ const {spawn}=require('node:child_process');
12
+ process.on('SIGTERM',()=>{});
13
+ const child=spawn(process.execPath,['-e', 'process.on("SIGTERM",()=>{}); setInterval(()=>{},1000)'],{stdio:'ignore'});
14
+ child.on('spawn',()=>process.stderr.write('ready\\n'));
15
+ setInterval(()=>{},1000);
16
+ `])
17
+ const client = new Client({ name: 'timeout-cleanup-test', version: '1' })
18
+ try { await assert.rejects(client.connect(transport, { timeout: 500 }), /timed out/i) }
19
+ finally { await transport.close() }
20
+ assert.equal(childStarted, true)
21
+ assert.equal(transport.cleanupVerified, true)
22
+ // Interrupting a pending call takes the same owned-group cleanup path.
23
+ const controller = new AbortController()
24
+ const interrupted = new OwnedTransport(process.env, () => {}, ['-e', 'process.stdin.resume(); setInterval(()=>{},1000)'])
25
+ const other = new Client({ name: 'interrupt-cleanup-test', version: '1' })
26
+ const pending = other.connect(interrupted, { timeout: 5000, signal: controller.signal })
27
+ await delay(50); controller.abort()
28
+ await assert.rejects(pending)
29
+ await interrupted.close()
30
+ assert.equal(interrupted.cleanupVerified, true)
31
+ }
32
+ console.log('Owned benchmark timeout/interrupt cleanup: PASS')
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict'
2
+ import { getAppReadiness } from '../../../src/observe/readiness-service.js'
3
+ import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
4
+ import { ToolsObserve } from '../../../src/observe/index.js'
5
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
6
+
7
+ async function run() {
8
+ const original = ToolsObserve.getUITreeHandler
9
+ let samples = 0
10
+ _setDeviceListersForTests({ listAndroidDevices: async () => [{ id: 'fixture', platform: 'android', osVersion: '16', model: 'Fixture', simulator: true }] })
11
+ ToolsObserve.getUITreeHandler = async () => { assert.fail('inconclusive probes must not clear a known device blocker') }
12
+ try {
13
+ const result = await withDeviceReadinessProbe(async (_cmd, args) => {
14
+ if (args.includes('power')) samples++
15
+ if (samples > 1) throw new Error('probe unavailable')
16
+ return { exitCode: 0, stderr: '', stdout: args.includes('power') ? 'mWakefulness=Asleep\nmWakefulnessChanging=false' : 'KeyguardServiceDelegate\n showing=false\n mIsShowing=false' }
17
+ }, () => getAppReadiness({ platform: 'android', deviceId: 'fixture', timeoutMs: 300, pollIntervalMs: 100 }, true))
18
+ assert.ok(samples > 1, 'exercise a later inconclusive probe in the same wait')
19
+ assert.equal(result.success, false)
20
+ assert.equal(result.failure_code, 'READINESS_TIMEOUT')
21
+ assert.deepEqual(result.device_readiness.blockers, ['DEVICE_ASLEEP'])
22
+ assert.equal(result.device_readiness.input_ready, false)
23
+ assert.ok(result.device_readiness.evidence.some(item => item.code === 'DEVICE_ASLEEP'))
24
+ assert.ok(result.device_readiness.limitations.includes('DEVICE_POWER_OBSERVATION_FAILED'))
25
+ assert.ok(result.readiness.limitations.includes('DEADLINE_EXCEEDED'))
26
+ console.log('PASS readiness deadline retains the earlier blocker and later probe failure')
27
+ } finally {
28
+ ToolsObserve.getUITreeHandler = original
29
+ _resetDeviceListersForTests()
30
+ }
31
+ }
32
+ run().catch(error => { console.error(error); process.exitCode = 1 })