mobile-debug-mcp 0.35.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 (247) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +96 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
  19. package/dist/contracts/device-readiness.js +1 -0
  20. package/dist/debugger/guard.js +4 -4
  21. package/dist/debugger/ios-worker.js +62 -0
  22. package/dist/debugger/ios.js +57 -0
  23. package/dist/debugger/sessions.js +33 -16
  24. package/dist/debugger/worker.js +6 -4
  25. package/dist/interact/index.js +12 -2
  26. package/dist/interact/waits/ui-change-wait.js +9 -2
  27. package/dist/interact/waits/ui-wait.js +7 -3
  28. package/dist/manage/index.js +3 -0
  29. package/dist/manage/ios.js +2 -0
  30. package/dist/observe/device-readiness.js +116 -0
  31. package/dist/observe/evidence.js +31 -0
  32. package/dist/observe/ios-trace/artifact.js +63 -0
  33. package/dist/observe/ios-trace/capabilities.js +86 -0
  34. package/dist/observe/ios-trace/native.js +93 -0
  35. package/dist/observe/ios-trace/notification.js +35 -0
  36. package/dist/observe/ios-trace/process.js +98 -0
  37. package/dist/observe/ios-trace/sessions.js +246 -0
  38. package/dist/observe/readiness-service.js +22 -10
  39. package/dist/observe/readiness.js +34 -4
  40. package/dist/observe/screenshot-encoding.js +4 -0
  41. package/dist/observe/snapshot-collection.js +7 -2
  42. package/dist/observe/system-trace-error.js +7 -0
  43. package/dist/observe/system-trace-router.js +81 -0
  44. package/dist/observe/system-trace.js +4 -8
  45. package/dist/server/definitions/manage.js +3 -2
  46. package/dist/server/handlers/action-execution.js +23 -14
  47. package/dist/server/handlers/compact-interaction.js +106 -0
  48. package/dist/server/handlers/compact-state.js +47 -0
  49. package/dist/server/handlers/debugger-handlers.js +27 -8
  50. package/dist/server/handlers/diagnostic.js +12 -4
  51. package/dist/server/handlers/input.js +2 -0
  52. package/dist/server/handlers/interact.js +2 -0
  53. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  54. package/dist/server/handlers/journey.js +18 -20
  55. package/dist/server/handlers/observe.js +2 -1
  56. package/dist/server/handlers/tool-handlers.js +28 -14
  57. package/dist/server/schemas/debugger-schema.js +10 -7
  58. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  59. package/dist/server/schemas/system-trace-schema.js +14 -9
  60. package/dist/server/tool-definitions.js +9 -4
  61. package/dist/server-core.js +5 -2
  62. package/dist/server.js +5 -5
  63. package/dist/system/android.js +19 -15
  64. package/dist/system/index.js +2 -1
  65. package/dist/utils/android/utils.js +7 -16
  66. package/dist/utils/app-installation.js +77 -0
  67. package/dist/utils/benchmark-metrics.js +43 -0
  68. package/dist/utils/exec.js +4 -0
  69. package/dist/utils/ios/utils.js +7 -11
  70. package/dist/utils/resolve-device.js +3 -2
  71. package/dist/utils/timing.js +3 -3
  72. package/docs/CHANGELOG.md +48 -1
  73. package/docs/agent-plugin-installation-verification.md +39 -0
  74. package/docs/benchmarks/README.md +1 -0
  75. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  76. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  77. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  78. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  79. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  80. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  81. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  82. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  83. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  84. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  85. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  86. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  87. package/docs/code-structure.md +3 -1
  88. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  89. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  90. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  91. package/docs/specs/031-ios-system-traces/plan.md +166 -0
  92. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  93. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  94. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  95. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  96. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  97. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  98. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  99. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  100. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  101. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  102. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  103. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  104. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  105. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  106. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  107. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  108. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  109. package/docs/specs/034-app-installation-status/intent.md +41 -0
  110. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  111. package/docs/specs/034-app-installation-status/plan.md +73 -0
  112. package/docs/specs/034-app-installation-status/spec.md +114 -0
  113. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  114. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  115. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  116. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  117. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  118. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  119. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  120. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  121. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  122. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  123. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  124. package/docs/tools/TOOLS.md +3 -2
  125. package/docs/tools/compact-interactions.md +56 -0
  126. package/docs/tools/correlated-evidence.md +6 -0
  127. package/docs/tools/debugger.md +45 -5
  128. package/docs/tools/interact.md +17 -8
  129. package/docs/tools/manage.md +10 -2
  130. package/docs/tools/observe.md +42 -2
  131. package/docs/tools/system-traces.md +81 -14
  132. package/docs/tools/system.md +6 -0
  133. package/package.json +2 -1
  134. package/scripts/agent-plugin.mjs +55 -5
  135. package/src/contracts/device-readiness.ts +8 -0
  136. package/src/debugger/android.ts +1 -1
  137. package/src/debugger/guard.ts +5 -5
  138. package/src/debugger/ios-worker.ts +49 -0
  139. package/src/debugger/ios.ts +45 -0
  140. package/src/debugger/lldb/breakpoints.py +51 -0
  141. package/src/debugger/lldb/inspection.py +109 -0
  142. package/src/debugger/lldb/worker.py +203 -0
  143. package/src/debugger/sessions.ts +24 -12
  144. package/src/debugger/worker.ts +7 -4
  145. package/src/interact/index.ts +13 -2
  146. package/src/interact/waits/ui-change-wait.ts +7 -2
  147. package/src/interact/waits/ui-wait.ts +6 -3
  148. package/src/manage/index.ts +2 -0
  149. package/src/manage/ios.ts +2 -0
  150. package/src/observe/device-readiness.ts +96 -0
  151. package/src/observe/evidence.ts +30 -1
  152. package/src/observe/ios-trace/artifact.ts +55 -0
  153. package/src/observe/ios-trace/capabilities.ts +74 -0
  154. package/src/observe/ios-trace/native.ts +72 -0
  155. package/src/observe/ios-trace/notification.ts +30 -0
  156. package/src/observe/ios-trace/process.ts +82 -0
  157. package/src/observe/ios-trace/sessions.ts +206 -0
  158. package/src/observe/readiness-service.ts +22 -10
  159. package/src/observe/readiness.ts +37 -4
  160. package/src/observe/screenshot-encoding.ts +4 -0
  161. package/src/observe/snapshot-collection.ts +7 -2
  162. package/src/observe/system-trace-error.ts +3 -0
  163. package/src/observe/system-trace-router.ts +61 -0
  164. package/src/observe/system-trace.ts +4 -4
  165. package/src/server/common.ts +2 -2
  166. package/src/server/definitions/manage.ts +3 -2
  167. package/src/server/handlers/action-execution.ts +22 -14
  168. package/src/server/handlers/compact-interaction.ts +91 -0
  169. package/src/server/handlers/compact-state.ts +43 -0
  170. package/src/server/handlers/debugger-handlers.ts +28 -9
  171. package/src/server/handlers/diagnostic.ts +11 -4
  172. package/src/server/handlers/input.ts +2 -0
  173. package/src/server/handlers/interact.ts +2 -0
  174. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  175. package/src/server/handlers/journey.ts +15 -17
  176. package/src/server/handlers/observe.ts +2 -1
  177. package/src/server/handlers/payload.ts +2 -1
  178. package/src/server/handlers/tool-handlers.ts +29 -14
  179. package/src/server/schemas/debugger-schema.ts +10 -7
  180. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  181. package/src/server/schemas/system-trace-schema.ts +15 -9
  182. package/src/server/tool-definitions.ts +8 -4
  183. package/src/server-core.ts +5 -2
  184. package/src/server.ts +4 -4
  185. package/src/system/android.ts +18 -11
  186. package/src/system/index.ts +2 -1
  187. package/src/types.ts +2 -0
  188. package/src/utils/android/utils.ts +8 -10
  189. package/src/utils/app-installation.ts +73 -0
  190. package/src/utils/benchmark-metrics.ts +42 -0
  191. package/src/utils/exec.ts +5 -0
  192. package/src/utils/ios/utils.ts +7 -10
  193. package/src/utils/resolve-device.ts +4 -2
  194. package/src/utils/timing.ts +3 -2
  195. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  196. package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
  197. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  198. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  199. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  200. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  201. package/test/device/automated/latency/end-to-end.ts +149 -0
  202. package/test/device/automated/latency/owned-transport.ts +81 -0
  203. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  204. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  205. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  206. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  207. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  208. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  209. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  210. package/test/fixtures/contracts/tool-definitions.json +243 -47
  211. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  212. package/test/fixtures/ios-debugger-app/README.md +27 -0
  213. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  214. package/test/fixtures/latency-journey-app/build.ts +3 -3
  215. package/test/helpers/ios-trace-shutdown.ts +29 -0
  216. package/test/unit/debugger/handle-guards.test.ts +89 -0
  217. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  218. package/test/unit/debugger/ios.test.ts +104 -0
  219. package/test/unit/debugger/lldb-worker.py +166 -0
  220. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  221. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  222. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  223. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  224. package/test/unit/observe/device-readiness.test.ts +97 -0
  225. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  226. package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
  227. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  228. package/test/unit/observe/readiness.test.ts +1 -1
  229. package/test/unit/observe/system-trace-router.test.ts +49 -0
  230. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  231. package/test/unit/server/compact-interaction.test.ts +87 -0
  232. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  233. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  234. package/test/unit/server/conditional_actions.test.ts +7 -3
  235. package/test/unit/server/correlated-evidence.test.ts +18 -6
  236. package/test/unit/server/end-to-end-report.test.ts +46 -0
  237. package/test/unit/server/input-device-readiness.test.ts +55 -0
  238. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  239. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  240. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  241. package/test/unit/server/readiness.test.ts +24 -1
  242. package/test/unit/server/run_journey.test.ts +2 -1
  243. package/test/unit/server/selector_tap_target.test.ts +2 -1
  244. package/test/unit/server/system-trace.test.ts +18 -7
  245. package/test/unit/utils/app-installation.test.ts +78 -0
  246. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  247. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict'
2
+ import { SystemTraceRouter } from '../../../src/observe/system-trace-router.js'
3
+ import type { SystemTraceSessions, TraceResult } from '../../../src/observe/system-trace.js'
4
+ import type { IosTraceSessions, IosTraceResult } from '../../../src/observe/ios-trace/sessions.js'
5
+ import type { IosTraceNative } from '../../../src/observe/ios-trace/native.js'
6
+ function backend(platform:'android'|'ios') {
7
+ let next=0,starts=0
8
+ const results=new Map<string,TraceResult|IosTraceResult>()
9
+ const api={
10
+ capabilities:async()=>({}),
11
+ start:async(device:string|{id:string})=>{
12
+ starts++
13
+ const result={trace_id:platform+ ++next,device:{platform,id:typeof device==='string'?device:device.id},state:'recording',cleanup:{status:'pending'}} as TraceResult|IosTraceResult
14
+ results.set(result.trace_id,result);return result
15
+ },
16
+ control:async(id:string,op:string)=>{
17
+ const result=results.get(id);if(!result)throw new Error('TRACE_NOT_FOUND')
18
+ if(op==='cancel'){result.cleanup.status='complete';result.state='cancelled'}
19
+ return result
20
+ },
21
+ link:async()=>async()=>({trace_id:'linked',coverage:'covered',clock_alignment:'unknown'}),
22
+ shutdown:async()=>[]
23
+ }
24
+ return {api,results,count:()=>starts}
25
+ }
26
+ async function run(){
27
+ const android=backend('android'),ios=backend('ios')
28
+ let release!:()=>void,gate=Promise.resolve()
29
+ const native={shutdown:()=>{},capabilities:async(device:string)=>{await gate;return {supported:true,device:{platform:'ios',id:device,simulator:true}}}} as unknown as IosTraceNative
30
+ const router=new SystemTraceRouter(android.api as unknown as SystemTraceSessions,ios.api as unknown as IosTraceSessions,native)
31
+ const a=await router.start('a'),b=await router.start('b',30000,{appId:'fixture'})
32
+ await assert.rejects(()=>router.link(b.trace_id,{platform:'android',id:'b'},'bad'),/TRACE_NOT_FOUND/)
33
+ await assert.rejects(()=>router.link(b.trace_id,{platform:'ios',id:'other'},'bad'),/TRACE_NOT_FOUND/)
34
+ await assert.rejects(()=>router.start('b',30000,{appId:'fixture'}),/TRACE_BUSY/)
35
+ await router.start('c');await router.start('d',30000,{appId:'fixture'})
36
+ await assert.rejects(()=>router.start('fifth'),/TRACE_BUSY/)
37
+ await router.control(a.trace_id,'cancel')
38
+ await router.start('freed')
39
+ await router.control(b.trace_id,'cancel')
40
+ gate=new Promise<void>(r=>{release=r})
41
+ const first=router.start('pending',30000,{appId:'fixture'})
42
+ await new Promise(r=>setImmediate(r))
43
+ await assert.rejects(()=>router.start('pending',30000,{appId:'fixture'}),/TRACE_BUSY/)
44
+ release();await first
45
+ assert.equal(ios.count(),3)
46
+ await router.shutdown();await assert.rejects(()=>router.start('closed'),/TRACE_BUSY/)
47
+ console.log('PASS cross-platform trace routing, pinned linkage, shared reservations and shutdown')
48
+ }
49
+ run().catch(error=>{console.error(error);process.exitCode=1})
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const root = fileURLToPath(new URL('../../../', import.meta.url))
9
+ const temporary = await mkdtemp(path.join(os.tmpdir(), 'agent-plugin-export-'))
10
+ const output = path.join(temporary, 'plugin')
11
+ const exportPlugin = () => spawnSync(process.execPath, ['scripts/agent-plugin.mjs', '--export-antigravity', output], { cwd: root, encoding: 'utf8', timeout: 10000 })
12
+ try {
13
+ const exported = exportPlugin()
14
+ assert.equal(exported.status, 0, exported.stderr)
15
+ assert.deepEqual((await readdir(output)).sort(), ['LICENSE', 'README.md', 'assets', 'mcp_config.json', 'plugin.json', 'skills'])
16
+ for (const directory of ['skills', 'assets']) {
17
+ const files = await readdir(path.join(output, directory), { recursive: true })
18
+ assert.ok(files.every(file => path.basename(file) !== '.DS_Store' && !path.basename(file).startsWith('._')), 'Export must exclude Finder metadata')
19
+ }
20
+ const manifest = JSON.parse(await readFile(path.join(output, 'plugin.json'), 'utf8'))
21
+ assert.equal(manifest.$schema, 'https://antigravity.google/schemas/v1/plugin.json')
22
+ assert.ok((await readFile(path.join(output, manifest.logo))).length > 0)
23
+ const config = JSON.parse(await readFile(path.join(output, 'mcp_config.json'), 'utf8'))
24
+ assert.equal(Object.keys(config.mcpServers).length, 1)
25
+ const server = config.mcpServers['mobile-debug-mcp']
26
+ assert.equal(server.command, 'npx')
27
+ assert.equal(server.cwd, '${PLUGIN_DATA}')
28
+ assert.equal('type' in server, false, 'Antigravity infers stdio transport; type causes a strict warning')
29
+ const skill = 'skills/mobile-debug-mcp/SKILL.md'
30
+ assert.deepEqual(await readFile(path.join(output, skill)), await readFile(path.join(root, 'agent-plugin', skill)))
31
+ assert.deepEqual(await readFile(path.join(output, 'LICENSE')), await readFile(path.join(root, 'LICENSE')))
32
+ await writeFile(path.join(output, 'keep.txt'), 'existing user data')
33
+ assert.notEqual(exportPlugin().status, 0, 'Existing destinations must not be overwritten or merged')
34
+ assert.equal(await readFile(path.join(output, 'keep.txt'), 'utf8'), 'existing user data')
35
+ console.log('PASS Antigravity export: native inventory, shared skill/license, no foreign manifests, overwrite refusal')
36
+ } finally {
37
+ await rm(temporary, { recursive: true, force: true })
38
+ }
@@ -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 })