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.
- package/.cursor-plugin/marketplace.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/README.md +96 -24
- package/agent-plugin/.antigravity-plugin/README.md +48 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +37 -8
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/cursor/LICENSE +201 -0
- package/agent-plugin/cursor/README.md +9 -0
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +13 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
- package/dist/contracts/device-readiness.js +1 -0
- package/dist/debugger/guard.js +4 -4
- package/dist/debugger/ios-worker.js +62 -0
- package/dist/debugger/ios.js +57 -0
- package/dist/debugger/sessions.js +33 -16
- package/dist/debugger/worker.js +6 -4
- package/dist/interact/index.js +12 -2
- package/dist/interact/waits/ui-change-wait.js +9 -2
- package/dist/interact/waits/ui-wait.js +7 -3
- package/dist/manage/index.js +3 -0
- package/dist/manage/ios.js +2 -0
- package/dist/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/artifact.js +63 -0
- package/dist/observe/ios-trace/capabilities.js +86 -0
- package/dist/observe/ios-trace/native.js +93 -0
- package/dist/observe/ios-trace/notification.js +35 -0
- package/dist/observe/ios-trace/process.js +98 -0
- package/dist/observe/ios-trace/sessions.js +246 -0
- package/dist/observe/readiness-service.js +22 -10
- package/dist/observe/readiness.js +34 -4
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +4 -8
- package/dist/server/definitions/manage.js +3 -2
- package/dist/server/handlers/action-execution.js +23 -14
- package/dist/server/handlers/compact-interaction.js +106 -0
- package/dist/server/handlers/compact-state.js +47 -0
- package/dist/server/handlers/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- package/dist/server/handlers/input.js +2 -0
- package/dist/server/handlers/interact.js +2 -0
- package/dist/server/handlers/interaction-diagnostics.js +62 -0
- package/dist/server/handlers/journey.js +18 -20
- package/dist/server/handlers/observe.js +2 -1
- package/dist/server/handlers/tool-handlers.js +28 -14
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +5 -5
- package/dist/system/android.js +19 -15
- package/dist/system/index.js +2 -1
- package/dist/utils/android/utils.js +7 -16
- package/dist/utils/app-installation.js +77 -0
- package/dist/utils/benchmark-metrics.js +43 -0
- package/dist/utils/exec.js +4 -0
- package/dist/utils/ios/utils.js +7 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +48 -1
- package/docs/agent-plugin-installation-verification.md +39 -0
- package/docs/benchmarks/README.md +1 -0
- package/docs/benchmarks/end-to-end-latency/README.md +98 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
- package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
- package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
- package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
- package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
- package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
- package/docs/code-structure.md +3 -1
- package/docs/specs/031-ios-system-traces/intent.md +38 -0
- package/docs/specs/031-ios-system-traces/investigation.md +92 -0
- package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
- package/docs/specs/031-ios-system-traces/plan.md +166 -0
- package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
- package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
- package/docs/specs/031-ios-system-traces/spec.md +135 -0
- package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
- package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
- package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
- package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
- package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
- package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
- package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
- package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
- package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
- package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
- package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
- package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
- package/docs/specs/034-app-installation-status/intent.md +41 -0
- package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
- package/docs/specs/034-app-installation-status/plan.md +73 -0
- package/docs/specs/034-app-installation-status/spec.md +114 -0
- package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
- package/docs/specs/035-compact-interaction-results/intent.md +38 -0
- package/docs/specs/035-compact-interaction-results/plan.md +43 -0
- package/docs/specs/035-compact-interaction-results/spec.md +118 -0
- package/docs/specs/035-compact-interaction-results/verification.md +111 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
- package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
- package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
- package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
- package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
- package/docs/tools/TOOLS.md +3 -2
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- package/docs/tools/debugger.md +45 -5
- package/docs/tools/interact.md +17 -8
- package/docs/tools/manage.md +10 -2
- package/docs/tools/observe.md +42 -2
- package/docs/tools/system-traces.md +81 -14
- package/docs/tools/system.md +6 -0
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- package/src/contracts/device-readiness.ts +8 -0
- package/src/debugger/android.ts +1 -1
- package/src/debugger/guard.ts +5 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/src/debugger/lldb/breakpoints.py +51 -0
- package/src/debugger/lldb/inspection.py +109 -0
- package/src/debugger/lldb/worker.py +203 -0
- package/src/debugger/sessions.ts +24 -12
- package/src/debugger/worker.ts +7 -4
- package/src/interact/index.ts +13 -2
- package/src/interact/waits/ui-change-wait.ts +7 -2
- package/src/interact/waits/ui-wait.ts +6 -3
- package/src/manage/index.ts +2 -0
- package/src/manage/ios.ts +2 -0
- package/src/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/artifact.ts +55 -0
- package/src/observe/ios-trace/capabilities.ts +74 -0
- package/src/observe/ios-trace/native.ts +72 -0
- package/src/observe/ios-trace/notification.ts +30 -0
- package/src/observe/ios-trace/process.ts +82 -0
- package/src/observe/ios-trace/sessions.ts +206 -0
- package/src/observe/readiness-service.ts +22 -10
- package/src/observe/readiness.ts +37 -4
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +4 -4
- package/src/server/common.ts +2 -2
- package/src/server/definitions/manage.ts +3 -2
- package/src/server/handlers/action-execution.ts +22 -14
- package/src/server/handlers/compact-interaction.ts +91 -0
- package/src/server/handlers/compact-state.ts +43 -0
- package/src/server/handlers/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- package/src/server/handlers/input.ts +2 -0
- package/src/server/handlers/interact.ts +2 -0
- package/src/server/handlers/interaction-diagnostics.ts +50 -0
- package/src/server/handlers/journey.ts +15 -17
- package/src/server/handlers/observe.ts +2 -1
- package/src/server/handlers/payload.ts +2 -1
- package/src/server/handlers/tool-handlers.ts +29 -14
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +4 -4
- package/src/system/android.ts +18 -11
- package/src/system/index.ts +2 -1
- package/src/types.ts +2 -0
- package/src/utils/android/utils.ts +8 -10
- package/src/utils/app-installation.ts +73 -0
- package/src/utils/benchmark-metrics.ts +42 -0
- package/src/utils/exec.ts +5 -0
- package/src/utils/ios/utils.ts +7 -10
- package/src/utils/resolve-device.ts +4 -2
- package/src/utils/timing.ts +3 -2
- package/test/device/automated/debugger/runtime.android.test.ts +5 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
- package/test/device/automated/interact/compact-results.smoke.ts +58 -0
- package/test/device/automated/latency/end-to-end-environment.ts +61 -0
- package/test/device/automated/latency/end-to-end-report.ts +90 -0
- package/test/device/automated/latency/end-to-end-session.ts +98 -0
- package/test/device/automated/latency/end-to-end.ts +149 -0
- package/test/device/automated/latency/owned-transport.ts +81 -0
- package/test/device/automated/latency/snapshot-pair.ts +143 -0
- package/test/device/automated/latency-ios/benchmark.ts +126 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
- package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
- package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +243 -47
- package/test/fixtures/ios-debugger-app/Main.swift +95 -0
- package/test/fixtures/ios-debugger-app/README.md +27 -0
- package/test/fixtures/ios-debugger-app/build.ts +21 -0
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +89 -0
- package/test/unit/debugger/ios-preparation.test.ts +57 -0
- package/test/unit/debugger/ios.test.ts +104 -0
- package/test/unit/debugger/lldb-worker.py +166 -0
- package/test/unit/debugger/lldb-worker.test.ts +10 -0
- package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
- package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
- package/test/unit/observe/device-readiness.test.ts +97 -0
- package/test/unit/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/system-trace-router.test.ts +49 -0
- package/test/unit/quality/agent-plugin-export.test.ts +38 -0
- package/test/unit/server/compact-interaction.test.ts +87 -0
- package/test/unit/server/compact-journey-evidence.test.ts +40 -0
- package/test/unit/server/compact-review-regressions.test.ts +78 -0
- package/test/unit/server/conditional_actions.test.ts +7 -3
- package/test/unit/server/correlated-evidence.test.ts +18 -6
- package/test/unit/server/end-to-end-report.test.ts +46 -0
- package/test/unit/server/input-device-readiness.test.ts +55 -0
- package/test/unit/server/interaction-diagnostics.test.ts +45 -0
- package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
- package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
- package/test/unit/server/readiness.test.ts +24 -1
- package/test/unit/server/run_journey.test.ts +2 -1
- package/test/unit/server/selector_tap_target.test.ts +2 -1
- package/test/unit/server/system-trace.test.ts +18 -7
- package/test/unit/utils/app-installation.test.ts +78 -0
- package/test/unit/utils/benchmark-metrics.test.ts +36 -0
- package/test/unit/utils/resolve_device.test.ts +11 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { summarize } from './report.js'
|
|
2
|
+
|
|
3
|
+
export interface Span { stage: string; start_ms: number; end_ms: number }
|
|
4
|
+
export interface RequestMetric {
|
|
5
|
+
event: 'latency_metric'; request_id: string; tool: string
|
|
6
|
+
handler_completed: boolean; elapsed_ms: number; spans: Span[]
|
|
7
|
+
}
|
|
8
|
+
export interface TreeMetric {
|
|
9
|
+
event: 'ui_tree_metric'; type: string; stage?: string; elapsed_ms?: number
|
|
10
|
+
acquisition_id?: string; success?: boolean; durations_ms?: Record<string, number>
|
|
11
|
+
}
|
|
12
|
+
export interface RequestSample {
|
|
13
|
+
tool: string; elapsed_ms: number; response_bytes?: number; success: boolean
|
|
14
|
+
failure?: string; failed_step?: string; failure_code?: string; metrics: RequestMetric[]; tree: TreeMetric[]
|
|
15
|
+
}
|
|
16
|
+
export interface Attempt {
|
|
17
|
+
case: string; iteration: number; warmup: boolean; success: boolean
|
|
18
|
+
failure?: string; setup_ms: number; requests: RequestSample[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function unionDuration(spans: Span[], limit: number) {
|
|
22
|
+
const sorted = spans.map(span => [Math.max(0, span.start_ms), Math.min(limit, span.end_ms)])
|
|
23
|
+
.filter(([start, end]) => Number.isFinite(start) && Number.isFinite(end) && end >= start)
|
|
24
|
+
.sort((a, b) => a[0] - b[0])
|
|
25
|
+
let end = 0, total = 0
|
|
26
|
+
for (const [start, stop] of sorted) { total += Math.max(0, stop - Math.max(end, start)); end = Math.max(end, stop) }
|
|
27
|
+
return total
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function summarizeAttempts(attempts: Attempt[]) {
|
|
31
|
+
return Object.fromEntries([...new Set(attempts.map(attempt => attempt.case))].map(name => {
|
|
32
|
+
const selected = attempts.filter(attempt => attempt.case === name)
|
|
33
|
+
const measured = selected.filter(attempt => !attempt.warmup)
|
|
34
|
+
const successful = measured.filter(attempt => attempt.success)
|
|
35
|
+
const requests = successful.flatMap(attempt => attempt.requests)
|
|
36
|
+
const metrics = requests.flatMap(request => request.metrics)
|
|
37
|
+
const tree = requests.flatMap(request => request.tree)
|
|
38
|
+
const stages = [...new Set(metrics.flatMap(metric => metric.spans.map(span => span.stage)))]
|
|
39
|
+
const treeStages = [...new Set(tree.filter(event => event.type === 'stage').map(event => event.stage!))]
|
|
40
|
+
return [name, {
|
|
41
|
+
attempts: measured.length, failures: measured.filter(attempt => !attempt.success).length,
|
|
42
|
+
warmup_failures: selected.filter(attempt => attempt.warmup && !attempt.success).length,
|
|
43
|
+
total_ms: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + request.elapsed_ms, 0))),
|
|
44
|
+
response_bytes: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + (request.response_bytes ?? 0), 0))),
|
|
45
|
+
handler_ms: summarize(metrics.map(metric => metric.elapsed_ms)),
|
|
46
|
+
handler_unattributed_ms: summarize(metrics.map(metric => Math.max(0, metric.elapsed_ms - unionDuration(metric.spans, metric.elapsed_ms)))),
|
|
47
|
+
client_minus_handler_ms: summarize(requests.filter(request => request.metrics.length === 1)
|
|
48
|
+
.map(request => request.elapsed_ms - request.metrics[0].elapsed_ms)),
|
|
49
|
+
inclusive_components_ms: Object.fromEntries(stages.map(stage => [stage, summarize(metrics.map(metric =>
|
|
50
|
+
metric.spans.filter(span => span.stage === stage).reduce((sum, span) => sum + span.end_ms - span.start_ms, 0)))])),
|
|
51
|
+
physical_reads: tree.filter(event => event.type === 'physical_read_completed').length,
|
|
52
|
+
failed_physical_reads: tree.filter(event => event.type === 'physical_read_completed' && event.success === false).length,
|
|
53
|
+
tree_stages_ms: Object.fromEntries(treeStages.map(stage => [stage, summarize(tree.filter(event => event.type === 'stage' && event.stage === stage).map(event => event.elapsed_ms ?? 0))])),
|
|
54
|
+
tree_acquisition_ms: summarize(tree
|
|
55
|
+
.filter(event => event.type === 'acquisition_completed').map(event => event.elapsed_ms ?? 0)),
|
|
56
|
+
missing_request_metrics: requests.filter(request => request.metrics.length !== 1).length
|
|
57
|
+
}]
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function observationOverhead(attempts: Attempt[]) {
|
|
62
|
+
return Object.fromEntries(['screenshot', 'tree', 'snapshot'].map(name => {
|
|
63
|
+
const pairs = attempts.filter(attempt => attempt.case === `${name}-on` && !attempt.warmup && attempt.success)
|
|
64
|
+
.flatMap(on => {
|
|
65
|
+
const off = attempts.find(attempt => attempt.case === `${name}-off` && attempt.iteration === on.iteration && !attempt.warmup && attempt.success)
|
|
66
|
+
return off ? [on.requests.reduce((sum, request) => sum + request.elapsed_ms, 0) - off.requests.reduce((sum, request) => sum + request.elapsed_ms, 0)] : []
|
|
67
|
+
})
|
|
68
|
+
return [name, { paired_on_minus_off_ms: summarize(pairs), interpretation: 'Includes device/order variability; not an isolated causal overhead estimate' }]
|
|
69
|
+
}))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Whitelist timing metadata rather than persisting unrestricted server stderr.
|
|
73
|
+
export function parseMetric(line: string): RequestMetric | TreeMetric | null {
|
|
74
|
+
try {
|
|
75
|
+
const value = JSON.parse(line)
|
|
76
|
+
const finite = (number: unknown) => typeof number === 'number' && Number.isFinite(number) && number >= 0
|
|
77
|
+
if (value.event === 'latency_metric' && typeof value.tool === 'string' && typeof value.request_id === 'string' &&
|
|
78
|
+
typeof value.handler_completed === 'boolean' && finite(value.elapsed_ms) && Array.isArray(value.spans) &&
|
|
79
|
+
value.spans.every((span: Span) => typeof span.stage === 'string' && finite(span.start_ms) && finite(span.end_ms) && span.end_ms >= span.start_ms)) {
|
|
80
|
+
return { event: value.event, request_id: value.request_id, tool: value.tool,
|
|
81
|
+
handler_completed: value.handler_completed, elapsed_ms: value.elapsed_ms,
|
|
82
|
+
spans: value.spans.map((span: Span) => ({ stage: span.stage, start_ms: span.start_ms, end_ms: span.end_ms })) }
|
|
83
|
+
}
|
|
84
|
+
if (value.event === 'ui_tree_metric' && typeof value.type === 'string' &&
|
|
85
|
+
(value.elapsed_ms === undefined || finite(value.elapsed_ms))) return { event: value.event, type: value.type,
|
|
86
|
+
stage: value.stage, elapsed_ms: value.elapsed_ms, acquisition_id: value.acquisition_id,
|
|
87
|
+
success: value.success, durations_ms: value.durations_ms }
|
|
88
|
+
} catch { /* Non-metric stderr is intentionally discarded. */ }
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import { OwnedTransport } from './owned-transport.js'
|
|
4
|
+
import { parseMetric, type RequestMetric, type TreeMetric, type RequestSample } from './end-to-end-report.js'
|
|
5
|
+
|
|
6
|
+
function object(value: unknown): Record<string, unknown> {
|
|
7
|
+
return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
|
|
8
|
+
}
|
|
9
|
+
export function validObservation(tool: string, data: Record<string, unknown>, content: unknown, deviceId: string) {
|
|
10
|
+
const treeValid = (value: Record<string, unknown>) => !value.error && Array.isArray(value.elements) && value.elements.length > 0 && object(value.device).id === deviceId
|
|
11
|
+
if (tool === 'get_ui_tree') return treeValid(data)
|
|
12
|
+
if (tool === 'capture_screenshot') return object(data.device).id === deviceId && Array.isArray(content) && content.some(item => {
|
|
13
|
+
const block = object(item)
|
|
14
|
+
return block.type === 'image' && typeof block.data === 'string' && block.data.length > 0
|
|
15
|
+
})
|
|
16
|
+
if (tool === 'capture_debug_snapshot') {
|
|
17
|
+
const raw = object(data.raw)
|
|
18
|
+
return treeValid(object(raw.ui_tree)) && !Object.keys(raw).some(key => key.endsWith('_error') && raw[key])
|
|
19
|
+
}
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class UnsafeMeasurement extends Error {}
|
|
24
|
+
export class BenchmarkSession {
|
|
25
|
+
readonly client = new Client({ name: 'end-to-end-latency', version: '1.0' })
|
|
26
|
+
readonly transport: OwnedTransport
|
|
27
|
+
private events: (RequestMetric | TreeMetric)[] = []
|
|
28
|
+
private pending = ''
|
|
29
|
+
private interrupted = new AbortController()
|
|
30
|
+
constructor(readonly deviceId: string, readonly instrumented: boolean, server = 'dist/server.js') {
|
|
31
|
+
this.transport = new OwnedTransport({ ...process.env,
|
|
32
|
+
MOBILE_DEBUG_MCP_LATENCY_METRICS: instrumented ? '1' : '0',
|
|
33
|
+
MOBILE_DEBUG_MCP_UI_TREE_METRICS: instrumented ? '1' : '0'
|
|
34
|
+
}, chunk => {
|
|
35
|
+
this.pending += chunk
|
|
36
|
+
let index: number
|
|
37
|
+
while ((index = this.pending.indexOf('\n')) >= 0) {
|
|
38
|
+
const event = parseMetric(this.pending.slice(0, index))
|
|
39
|
+
if (event) this.events.push(event)
|
|
40
|
+
this.pending = this.pending.slice(index + 1)
|
|
41
|
+
}
|
|
42
|
+
if (this.pending.length > 1024 * 1024) this.pending = ''
|
|
43
|
+
}, [server])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async connect() { await this.client.connect(this.transport, { timeout: 15000 }) }
|
|
47
|
+
abort() { this.interrupted.abort() }
|
|
48
|
+
async close() { await this.client.close(); await this.transport.close() }
|
|
49
|
+
|
|
50
|
+
async call(tool: string, args: Record<string, unknown>, samples: RequestSample[]) {
|
|
51
|
+
if (this.interrupted.signal.aborted) throw new UnsafeMeasurement('INTERRUPTED')
|
|
52
|
+
this.events = []
|
|
53
|
+
const started = performance.now()
|
|
54
|
+
const sample: RequestSample = { tool, elapsed_ms: 0, success: false, metrics: [], tree: [] }
|
|
55
|
+
samples.push(sample)
|
|
56
|
+
try {
|
|
57
|
+
const response = await this.client.callTool({ name: tool,
|
|
58
|
+
arguments: { platform: 'android', deviceId: this.deviceId, ...args }
|
|
59
|
+
}, undefined, { timeout: 300000, signal: this.interrupted.signal })
|
|
60
|
+
sample.elapsed_ms = performance.now() - started
|
|
61
|
+
console.log(` ${tool}: ${Math.round(sample.elapsed_ms)} ms`)
|
|
62
|
+
sample.response_bytes = Buffer.byteLength(JSON.stringify(response))
|
|
63
|
+
// Stderr and stdout are independent streams. Await the matching metric
|
|
64
|
+
// outside the measured round trip before dispatching the next request.
|
|
65
|
+
const deadline = performance.now() + 1000
|
|
66
|
+
while (this.instrumented && !this.events.some(event => event.event === 'latency_metric') && performance.now() < deadline) await delay(5)
|
|
67
|
+
sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
|
|
68
|
+
sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
|
|
69
|
+
const text = (response.content as { type: string; text?: string }[]).find(item => item.type === 'text')?.text
|
|
70
|
+
const data = JSON.parse(text ?? '{}')
|
|
71
|
+
sample.success = !response.isError && data.success !== false && !data.error && data.status !== 'timeout' && validObservation(tool, data, response.content, this.deviceId)
|
|
72
|
+
if (!sample.success) {
|
|
73
|
+
sample.failure = 'TOOL_OUTCOME_FAILED'
|
|
74
|
+
// Only fixture-generated step IDs and enum-style codes may be saved.
|
|
75
|
+
const step = typeof data.stopped_at_step_id === 'string' ? data.stopped_at_step_id : ''
|
|
76
|
+
if (/^(launch|tap-\d+|assert-\d+)$/.test(step)) sample.failed_step = step
|
|
77
|
+
const failed = Array.isArray(data.steps) ? data.steps.find((value: Record<string, unknown>) => value.id === step) : undefined
|
|
78
|
+
const code = data.failure_code ?? object(object(failed).result).failure_code ?? object(data.error).code
|
|
79
|
+
if (typeof code === 'string' && /^[A-Z][A-Z_]{0,63}$/.test(code)) sample.failure_code = code
|
|
80
|
+
throw new Error(sample.failure)
|
|
81
|
+
}
|
|
82
|
+
if (this.instrumented && (sample.metrics.length !== 1 || sample.metrics[0].tool !== tool)) {
|
|
83
|
+
sample.success = false; sample.failure = 'METRIC_CORRELATION_FAILED'
|
|
84
|
+
throw new UnsafeMeasurement(sample.failure)
|
|
85
|
+
}
|
|
86
|
+
return data as Record<string, unknown>
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!sample.elapsed_ms) sample.elapsed_ms = performance.now() - started
|
|
89
|
+
sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
|
|
90
|
+
sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
|
|
91
|
+
if (!sample.failure) {
|
|
92
|
+
sample.failure = this.interrupted.signal.aborted ? 'INTERRUPTED' : 'TRANSPORT_OR_RESPONSE_FAILED'
|
|
93
|
+
throw new UnsafeMeasurement(sample.failure)
|
|
94
|
+
}
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
|
|
3
|
+
import { BenchmarkSession, UnsafeMeasurement } from './end-to-end-session.js'
|
|
4
|
+
import { cleanupFixture, command, environment, fixturePresent, treeHash } from './end-to-end-environment.js'
|
|
5
|
+
import { summarizeAttempts, observationOverhead, type Attempt } from './end-to-end-report.js'
|
|
6
|
+
|
|
7
|
+
const deviceId = process.env.LATENCY_DEVICE_ID
|
|
8
|
+
if (!deviceId || !deviceId.startsWith('emulator-')) throw new Error('Set LATENCY_DEVICE_ID to an explicit Android emulator serial')
|
|
9
|
+
const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
|
|
10
|
+
const appId = 'dev.mobiledebugmcp.latencyfixture'
|
|
11
|
+
const smoke = process.argv.includes('--smoke')
|
|
12
|
+
const rounds = smoke ? 1 : 13
|
|
13
|
+
const warmups = smoke ? 0 : 3
|
|
14
|
+
const cases = ['standalone', 'full-debug', 'full-compact', 'light-debug', 'light-compact',
|
|
15
|
+
'screenshot-on', 'screenshot-off', 'tree-on', 'tree-off', 'snapshot-on', 'snapshot-off']
|
|
16
|
+
const flow = [['saved-session', 'idle-detail'], ['activate', 'active-detail'], ['home', 'home-screen'],
|
|
17
|
+
['saved-session', 'active-detail'], ['deactivate', 'idle-detail'], ['stop', 'stopped']]
|
|
18
|
+
const selector = (id: string) => ({ accessibility_id: id })
|
|
19
|
+
const controls = { actionTimeoutMs: 30000, verificationTimeoutMs: 60000 }
|
|
20
|
+
const attempts: Attempt[] = []
|
|
21
|
+
const phases: { phase: string; elapsed_ms: number; success: boolean }[] = []
|
|
22
|
+
const sessions = new Map<boolean, BenchmarkSession>()
|
|
23
|
+
const cleanup: { server?: boolean; fixture?: boolean; removed_install?: boolean } = {}
|
|
24
|
+
let metadata: ReturnType<typeof environment> | undefined
|
|
25
|
+
let interrupted = false
|
|
26
|
+
let fatal: string | undefined
|
|
27
|
+
let fixtureExisted = false
|
|
28
|
+
let installAttempted = false
|
|
29
|
+
let sourceStable: boolean | undefined
|
|
30
|
+
const startedAt = new Date().toISOString()
|
|
31
|
+
const root = 'docs/benchmarks/end-to-end-latency'
|
|
32
|
+
const reportName = smoke ? 'smoke' : 'baseline'
|
|
33
|
+
async function phase<T>(name: string, operation: () => T | Promise<T>) {
|
|
34
|
+
const start = performance.now()
|
|
35
|
+
let success = false
|
|
36
|
+
try { const result = await operation(); success = true; return result }
|
|
37
|
+
finally { phases.push({ phase: name, elapsed_ms: performance.now() - start, success }) }
|
|
38
|
+
}
|
|
39
|
+
function save() {
|
|
40
|
+
mkdirSync(root, { recursive: true })
|
|
41
|
+
const summaries = summarizeAttempts(attempts)
|
|
42
|
+
const report = { status: fatal ? 'stopped' : interrupted ? 'interrupted' : attempts.length === rounds * cases.length ? 'finished' : 'in_progress',
|
|
43
|
+
performance_evidence: !smoke && sourceStable !== false, smoke, warmups, measured_attempts_per_case: smoke ? 1 : 10,
|
|
44
|
+
started_at: startedAt, source_and_build_unchanged: sourceStable,
|
|
45
|
+
planned_cases: cases, unrun_attempts: rounds * cases.length - attempts.length,
|
|
46
|
+
outcome: fatal || interrupted || attempts.some(value => !value.success) || cleanup.server === false || cleanup.fixture === false ? 'fail' : cleanup.server && cleanup.fixture ? 'pass' : 'pending',
|
|
47
|
+
invocation: `LATENCY_DEVICE_ID=[emulator] MCP_ADB_TIMEOUT=${process.env.MCP_ADB_TIMEOUT ?? '20000'} npx tsx test/device/automated/latency/end-to-end.ts${smoke ? ' --smoke' : ''}`,
|
|
48
|
+
metadata, phases, fatal, cleanup, summaries, observation_overhead: observationOverhead(attempts), attempts }
|
|
49
|
+
writeFileSync(`${root}/${reportName}.json`, JSON.stringify(report, null, 2) + '\n')
|
|
50
|
+
const rows = Object.entries(summaries).map(([name, value]) => `| ${name} | ${value.total_ms?.count ?? 0}/${value.attempts} | ${value.total_ms?.p50.toFixed(1) ?? 'n/a'} | ${value.total_ms?.p95.toFixed(1) ?? 'n/a'} | ${value.failures} | ${value.warmup_failures} |`)
|
|
51
|
+
writeFileSync(`${root}/${reportName}.md`, `# End-to-end latency ${smoke ? 'smoke (not performance evidence)' : 'baseline'}\n\nStatus: ${report.status}. Build: ${metadata?.build_sha256 ?? 'unavailable'}.\n\n| Case | Successful/measured | p50 ms | p95 ms | Failures | Warm-up failures |\n| --- | ---: | ---: | ---: | ---: | ---: |\n${rows.join('\n')}\n\nSee ${reportName}.json for exact source/build hashes, environment, phase durations, attempt order, inclusive components and failure records. Percentiles use the successful measured count shown for each case. With 1–19 successful samples, nearest-rank p95 is the sample maximum. Zero successful samples yield no percentiles. Failed attempts are excluded from percentiles, retained in raw data, and fail the run.\n\nIntervals can overlap; component sums are not an additive latency budget. Client-minus-handler includes scheduling, transport and uninstrumented work. Handler residual excludes the union of recorded spans. Tree acquisition/parse/retry events are separate nested diagnostics. Application work inside requests remains included. Startup, fixture builds/resets and external validation are excluded from steady-state round trips. SDK wire serialization is not isolated.\n`)
|
|
52
|
+
}
|
|
53
|
+
async function reset(session: BenchmarkSession, observation: boolean) {
|
|
54
|
+
adb('shell', 'am', 'force-stop', appId)
|
|
55
|
+
if (!adb('shell', 'pm', 'clear', appId).includes('Success')) throw new Error('RESET_FAILED')
|
|
56
|
+
if (observation) {
|
|
57
|
+
adb('shell', 'am', 'start', '-W', '-n', `${appId}/.MainActivity`)
|
|
58
|
+
await session.call('expect_element_visible', { selector: selector('home-screen'), timeout_ms: 30000, poll_interval_ms: 100 }, [])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function journey(session: BenchmarkSession, mode: string, attempt: Attempt) {
|
|
62
|
+
if (mode === 'standalone') {
|
|
63
|
+
await session.call('start_app', { appId, ...controls }, attempt.requests)
|
|
64
|
+
for (const [target, expected] of flow) {
|
|
65
|
+
const found = await session.call('wait_for_ui', { selector: selector(target), condition: 'clickable', timeout_ms: 10000, poll_interval_ms: 100 }, attempt.requests)
|
|
66
|
+
const element = found.element as { elementId?: string } | undefined
|
|
67
|
+
if (!element?.elementId) throw new Error('TARGET_HANDLE_MISSING')
|
|
68
|
+
await session.call('tap_element', { elementId: element.elementId, ...controls }, attempt.requests)
|
|
69
|
+
await session.call('expect_element_visible', { selector: selector(expected), timeout_ms: 30000, poll_interval_ms: 100 }, attempt.requests)
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const [verificationMode, responseMode] = mode.split('-')
|
|
74
|
+
const steps: Record<string, unknown>[] = [{ id: 'launch', type: 'start_app', appId }]
|
|
75
|
+
flow.forEach(([target, expected], index) => {
|
|
76
|
+
steps.push({ id: `tap-${index}`, type: 'tap', selector: selector(target), waitFor: { condition: 'clickable', timeoutMs: 10000, pollIntervalMs: 100 } })
|
|
77
|
+
steps.push({ id: `assert-${index}`, type: 'assert', assertion: { kind: 'element_visible', selector: selector(expected) }, timeoutMs: 30000, pollIntervalMs: 100 })
|
|
78
|
+
})
|
|
79
|
+
await session.call('run_journey', { defaults: { verificationMode, ...controls }, responseMode, steps }, attempt.requests)
|
|
80
|
+
}
|
|
81
|
+
async function attempt(name: string, iteration: number) {
|
|
82
|
+
if (interrupted) throw new UnsafeMeasurement('INTERRUPTED')
|
|
83
|
+
const instrumented = !name.endsWith('-off')
|
|
84
|
+
const session = sessions.get(instrumented)!
|
|
85
|
+
const observation = /^(screenshot|tree|snapshot)-/.test(name)
|
|
86
|
+
const value: Attempt = { case: name, iteration, warmup: iteration < warmups, success: false, setup_ms: 0, requests: [] }
|
|
87
|
+
attempts.push(value)
|
|
88
|
+
const start = performance.now()
|
|
89
|
+
try {
|
|
90
|
+
await reset(session, observation)
|
|
91
|
+
value.setup_ms = performance.now() - start
|
|
92
|
+
if (!observation) await journey(session, name, value)
|
|
93
|
+
else {
|
|
94
|
+
const tool = name.startsWith('screenshot') ? 'capture_screenshot' : name.startsWith('tree') ? 'get_ui_tree' : 'capture_debug_snapshot'
|
|
95
|
+
await session.call(tool, tool === 'capture_debug_snapshot' ? { includeScreenshot: false, includeLogs: false } : {}, value.requests)
|
|
96
|
+
}
|
|
97
|
+
value.success = true
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!value.setup_ms) value.setup_ms = performance.now() - start
|
|
100
|
+
value.failure = error instanceof UnsafeMeasurement ? error.message : value.requests.at(-1)?.failure ?? 'SETUP_OR_ASSERTION_FAILED'
|
|
101
|
+
if (error instanceof UnsafeMeasurement || !value.requests.length) throw new UnsafeMeasurement(value.failure)
|
|
102
|
+
} finally { save() }
|
|
103
|
+
}
|
|
104
|
+
async function main() {
|
|
105
|
+
const signal = () => { interrupted = true; for (const session of sessions.values()) session.abort() }
|
|
106
|
+
process.on('SIGINT', signal); process.on('SIGTERM', signal)
|
|
107
|
+
try {
|
|
108
|
+
if (adb('get-state') !== 'device' || adb('shell', 'getprop', 'ro.kernel.qemu') !== '1') throw new Error('EMULATOR_REQUIRED')
|
|
109
|
+
fixtureExisted = fixturePresent(adb, appId)
|
|
110
|
+
const apk = await phase('fixture_build', buildFixture)
|
|
111
|
+
metadata = environment(deviceId!, apk)
|
|
112
|
+
installAttempted = true
|
|
113
|
+
await phase('fixture_install', () => adb('install', '-r', apk))
|
|
114
|
+
for (const enabled of [true, false]) {
|
|
115
|
+
const session = new BenchmarkSession(deviceId!, enabled)
|
|
116
|
+
sessions.set(enabled, session)
|
|
117
|
+
await phase(`server_startup_metrics_${enabled}`, () => session.connect())
|
|
118
|
+
}
|
|
119
|
+
for (let round = 0; round < rounds; round++) {
|
|
120
|
+
const order = round % 2 ? [...cases].reverse() : cases
|
|
121
|
+
for (const name of order) {
|
|
122
|
+
console.log(`${smoke ? 'smoke' : `round ${round + 1}/${rounds}`} ${name}`)
|
|
123
|
+
await attempt(name, round)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
fatal = error instanceof UnsafeMeasurement ? error.message : 'PREREQUISITE_OR_CLEANUP_FAILED'
|
|
128
|
+
process.exitCode = 1
|
|
129
|
+
} finally {
|
|
130
|
+
cleanup.server = true
|
|
131
|
+
for (const session of sessions.values()) {
|
|
132
|
+
try { await session.close(); cleanup.server &&= session.transport.cleanupVerified }
|
|
133
|
+
catch { cleanup.server = false }
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
if (installAttempted && cleanupFixture(adb, appId, !fixtureExisted)) cleanup.removed_install = true
|
|
137
|
+
cleanup.fixture = true
|
|
138
|
+
} catch { cleanup.fixture = false }
|
|
139
|
+
if (!cleanup.server || !cleanup.fixture || interrupted || attempts.some(value => !value.success)) process.exitCode = 1
|
|
140
|
+
if (!cleanup.server || !cleanup.fixture) fatal = 'CLEANUP_FAILED'
|
|
141
|
+
if (metadata) {
|
|
142
|
+
sourceStable = metadata.source_sha256 === treeHash('src') && metadata.build_sha256 === treeHash('dist') && metadata.harness_sha256 === treeHash('test/device/automated/latency')
|
|
143
|
+
if (!sourceStable) { fatal = 'MEASURED_SOURCE_CHANGED'; process.exitCode = 1 }
|
|
144
|
+
}
|
|
145
|
+
save()
|
|
146
|
+
process.off('SIGINT', signal); process.off('SIGTERM', signal)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
main().catch(() => { console.error('Benchmark failed; inspect timing report'); process.exitCode = 1 })
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js'
|
|
4
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
|
5
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
|
|
6
|
+
|
|
7
|
+
// A dedicated POSIX process group bounds cleanup to this benchmark's server
|
|
8
|
+
// and its children. The SDK client and stdio wire protocol are unchanged.
|
|
9
|
+
export class OwnedTransport implements Transport {
|
|
10
|
+
onclose?: () => void
|
|
11
|
+
onerror?: (error: Error) => void
|
|
12
|
+
onmessage?: (message: JSONRPCMessage) => void
|
|
13
|
+
private child?: ChildProcessWithoutNullStreams
|
|
14
|
+
private buffer = new ReadBuffer({ maxBufferSize: 64 * 1024 * 1024 })
|
|
15
|
+
private closed?: Promise<void>
|
|
16
|
+
private closing?: Promise<void>
|
|
17
|
+
cleanupVerified = false
|
|
18
|
+
constructor(private env: NodeJS.ProcessEnv, private stderr: (chunk: string) => void,
|
|
19
|
+
private args = ['dist/server.js']) {}
|
|
20
|
+
|
|
21
|
+
async start() {
|
|
22
|
+
if (process.platform === 'win32') throw new Error('POSIX benchmark transport required')
|
|
23
|
+
if (this.child) throw new Error('Already started')
|
|
24
|
+
this.child = spawn(process.execPath, this.args, { env: this.env, detached: true, stdio: 'pipe' })
|
|
25
|
+
this.closed = new Promise(resolve => this.child!.once('close', () => { this.onclose?.(); resolve() }))
|
|
26
|
+
this.child.on('error', error => this.onerror?.(error))
|
|
27
|
+
this.child.stdin.on('error', error => this.onerror?.(error))
|
|
28
|
+
this.child.stderr.on('data', chunk => this.stderr(String(chunk)))
|
|
29
|
+
this.child.stdout.on('data', chunk => {
|
|
30
|
+
try {
|
|
31
|
+
this.buffer.append(chunk)
|
|
32
|
+
let message
|
|
33
|
+
while ((message = this.buffer.readMessage()) !== null) this.onmessage?.(message)
|
|
34
|
+
} catch (error) { this.onerror?.(error as Error) }
|
|
35
|
+
})
|
|
36
|
+
await new Promise<void>((resolve, reject) => { this.child!.once('spawn', resolve); this.child!.once('error', reject) })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async send(message: JSONRPCMessage) {
|
|
40
|
+
if (!this.child) throw new Error('Not started')
|
|
41
|
+
await new Promise<void>((resolve, reject) => this.child!.stdin.write(serializeMessage(message), error => error ? reject(error) : resolve()))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private groupExists() {
|
|
45
|
+
if (!this.child?.pid) return false
|
|
46
|
+
try { process.kill(-this.child.pid, 0); return true }
|
|
47
|
+
catch (error) {
|
|
48
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
49
|
+
if (code === 'ESRCH') return false
|
|
50
|
+
// Darwin can return EPERM while a killed group is being reaped. It is
|
|
51
|
+
// still unconfirmed: retry until ESRCH, never count EPERM as absence.
|
|
52
|
+
if (code === 'EPERM') return true
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private signal(signal: NodeJS.Signals) {
|
|
58
|
+
if (!this.child?.pid) return
|
|
59
|
+
try { process.kill(-this.child.pid, signal) }
|
|
60
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close() {
|
|
64
|
+
this.closing ??= this.cleanup()
|
|
65
|
+
return this.closing
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async cleanup() {
|
|
69
|
+
if (!this.child) { this.cleanupVerified = true; return }
|
|
70
|
+
this.child.stdin.end()
|
|
71
|
+
await Promise.race([this.closed, delay(1000)])
|
|
72
|
+
if (this.groupExists()) this.signal('SIGTERM')
|
|
73
|
+
await Promise.race([this.closed, delay(1000)])
|
|
74
|
+
if (this.groupExists()) this.signal('SIGKILL')
|
|
75
|
+
const deadline = performance.now() + 3000
|
|
76
|
+
while (this.groupExists() && performance.now() < deadline) await delay(50)
|
|
77
|
+
this.cleanupVerified = !this.groupExists()
|
|
78
|
+
this.buffer.clear()
|
|
79
|
+
if (!this.cleanupVerified) throw new Error('Owned process group did not exit')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute } from 'node:path'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { computeScreenFingerprint } from '../../../../src/utils/ui/index.js'
|
|
5
|
+
import type { CaptureDebugSnapshotRawResponse, GetCurrentScreenResponse } from '../../../../src/types.js'
|
|
6
|
+
import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
|
|
7
|
+
import { BenchmarkSession, UnsafeMeasurement } from './end-to-end-session.js'
|
|
8
|
+
import { cleanupFixture, command, environment, fixturePresent, treeHash } from './end-to-end-environment.js'
|
|
9
|
+
import { summarizeAttempts, type Attempt } from './end-to-end-report.js'
|
|
10
|
+
|
|
11
|
+
const device = process.env.LATENCY_DEVICE_ID ?? ''
|
|
12
|
+
const baseline = process.env.SNAPSHOT_BASELINE_SERVER ?? ''
|
|
13
|
+
const baselineSource = process.env.SNAPSHOT_BASELINE_SOURCE_SHA ?? ''
|
|
14
|
+
assert.match(device, /^emulator-\d+$/)
|
|
15
|
+
assert.ok(isAbsolute(baseline) && /^[a-f0-9]{64}$/.test(baselineSource), 'Provide the frozen baseline server path and source hash')
|
|
16
|
+
const adb = (...args: string[]) => command('adb', ['-s', device, ...args])
|
|
17
|
+
const app = 'dev.mobiledebugmcp.latencyfixture'
|
|
18
|
+
const fixtureActivity = `${app}.MainActivity`
|
|
19
|
+
const options = { includeScreenshot: false, includeLogs: false }
|
|
20
|
+
const attempts: Attempt[] = []
|
|
21
|
+
const sessions = {
|
|
22
|
+
baseline: new BenchmarkSession(device, true, baseline),
|
|
23
|
+
candidate: new BenchmarkSession(device, true)
|
|
24
|
+
}
|
|
25
|
+
type Build = keyof typeof sessions
|
|
26
|
+
let installAttempted = false, launched = false, interrupted = false
|
|
27
|
+
let fatal: string | undefined, metadata: ReturnType<typeof environment> | undefined
|
|
28
|
+
let fixtureSha: string | undefined
|
|
29
|
+
let nativeFreshness = false
|
|
30
|
+
const baselineHash = treeHash(dirname(baseline))
|
|
31
|
+
const cleanup = { servers: false, fixture: false }
|
|
32
|
+
|
|
33
|
+
async function checkFixtureActivity(session: BenchmarkSession) {
|
|
34
|
+
const screen = await session.call('get_current_screen', {}, [])
|
|
35
|
+
assert.equal(screen.package, app)
|
|
36
|
+
assert.equal(screen.activity, fixtureActivity)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function verifyFingerprint(raw: CaptureDebugSnapshotRawResponse) {
|
|
40
|
+
const tree = raw.ui_tree
|
|
41
|
+
assert.ok(tree && tree.device.id === device && tree.snapshot_revision)
|
|
42
|
+
assert.equal(raw.snapshot_revision, tree.snapshot_revision)
|
|
43
|
+
assert.equal(raw.captured_at_ms, tree.captured_at_ms)
|
|
44
|
+
assert.match(raw.fingerprint ?? '', /^[a-f0-9]{64}$/)
|
|
45
|
+
const current: GetCurrentScreenResponse = { device: tree.device, activity: fixtureActivity, package: app, shortActivity: '' }
|
|
46
|
+
assert.equal(raw.fingerprint, computeScreenFingerprint(tree, current, 'android').fingerprint)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function sample(build: Build, iteration: number) {
|
|
50
|
+
if (interrupted) throw new UnsafeMeasurement('INTERRUPTED')
|
|
51
|
+
const attempt: Attempt = { case: build, iteration, warmup: iteration < 3, success: false, setup_ms: 0, requests: [] }
|
|
52
|
+
attempts.push(attempt)
|
|
53
|
+
try {
|
|
54
|
+
await checkFixtureActivity(sessions[build])
|
|
55
|
+
const data = await sessions[build].call('capture_debug_snapshot', options, attempt.requests)
|
|
56
|
+
const raw = data.raw as CaptureDebugSnapshotRawResponse
|
|
57
|
+
assert.ok(raw.ui_tree?.elements.some(element => element.contentDescription === 'home-screen' || element.text === 'home-screen'))
|
|
58
|
+
await checkFixtureActivity(sessions[build])
|
|
59
|
+
if (build === 'candidate') {
|
|
60
|
+
verifyFingerprint(raw)
|
|
61
|
+
assert.equal(attempt.requests[0].tree.filter(event => event.type === 'acquisition_completed').length, 1)
|
|
62
|
+
}
|
|
63
|
+
attempt.success = true
|
|
64
|
+
} catch (error) {
|
|
65
|
+
attempt.failure = attempt.requests[0]?.failure ?? 'SNAPSHOT_ASSERTION_FAILED'
|
|
66
|
+
if (error instanceof UnsafeMeasurement) throw error
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function checkFreshness() {
|
|
71
|
+
const candidate = sessions.candidate
|
|
72
|
+
const before = (await candidate.call('capture_debug_snapshot', options, [])).raw as CaptureDebugSnapshotRawResponse
|
|
73
|
+
await candidate.call('run_journey', { steps: [
|
|
74
|
+
{ id: 'open', type: 'tap', selector: { accessibility_id: 'saved-session' } },
|
|
75
|
+
{ id: 'check', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'idle-detail' } }, timeoutMs: 15000 }
|
|
76
|
+
], defaults: { verificationMode: 'light' }, responseMode: 'compact' }, [])
|
|
77
|
+
await checkFixtureActivity(candidate)
|
|
78
|
+
const after = (await candidate.call('capture_debug_snapshot', options, [])).raw as CaptureDebugSnapshotRawResponse
|
|
79
|
+
await checkFixtureActivity(candidate)
|
|
80
|
+
verifyFingerprint(after)
|
|
81
|
+
assert.ok(after.ui_tree?.elements.some(element => element.contentDescription === 'idle-detail'))
|
|
82
|
+
assert.ok(after.snapshot_revision > before.snapshot_revision)
|
|
83
|
+
assert.notEqual(after.fingerprint, before.fingerprint)
|
|
84
|
+
nativeFreshness = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const stop = () => { interrupted = true; for (const session of Object.values(sessions)) session.abort() }
|
|
89
|
+
process.on('SIGINT', stop); process.on('SIGTERM', stop)
|
|
90
|
+
try {
|
|
91
|
+
assert.equal(adb('get-state'), 'device')
|
|
92
|
+
assert.equal(adb('shell', 'getprop', 'ro.kernel.qemu'), '1')
|
|
93
|
+
const apk = buildFixture()
|
|
94
|
+
if (!fixturePresent(adb, app)) {
|
|
95
|
+
installAttempted = true
|
|
96
|
+
adb('install', apk)
|
|
97
|
+
}
|
|
98
|
+
const packagePath = adb('shell', 'pm', 'path', app).match(/^package:(.+)$/)?.[1]
|
|
99
|
+
assert.ok(packagePath)
|
|
100
|
+
fixtureSha = adb('shell', 'sha256sum', packagePath).split(/\s+/)[0]
|
|
101
|
+
assert.match(fixtureSha, /^[a-f0-9]{64}$/)
|
|
102
|
+
metadata = { ...environment(device, apk), fixture_sha256: fixtureSha }
|
|
103
|
+
adb('shell', 'am', 'force-stop', app)
|
|
104
|
+
launched = true
|
|
105
|
+
adb('shell', 'am', 'start', '-W', '-n', `${app}/.MainActivity`)
|
|
106
|
+
for (const session of Object.values(sessions)) await session.connect()
|
|
107
|
+
await sessions.baseline.call('expect_element_visible', { selector: { accessibility_id: 'home-screen' }, timeout_ms: 30000 }, [])
|
|
108
|
+
for (let iteration = 0; iteration < 13; iteration++) {
|
|
109
|
+
const order: Build[] = iteration % 2 ? ['candidate', 'baseline'] : ['baseline', 'candidate']
|
|
110
|
+
for (const build of order) {
|
|
111
|
+
console.log(`Round ${iteration + 1}/13 ${build}`)
|
|
112
|
+
await sample(build, iteration)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await checkFreshness()
|
|
116
|
+
} catch (error) { fatal = error instanceof UnsafeMeasurement ? error.message : 'SETUP_OR_FRESHNESS_FAILED' }
|
|
117
|
+
finally {
|
|
118
|
+
cleanup.servers = true
|
|
119
|
+
for (const session of Object.values(sessions)) {
|
|
120
|
+
try { await session.close(); cleanup.servers &&= session.transport.cleanupVerified }
|
|
121
|
+
catch { cleanup.servers = false }
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
if (launched || installAttempted) cleanupFixture(adb, app, installAttempted)
|
|
125
|
+
cleanup.fixture = true
|
|
126
|
+
} catch { cleanup.fixture = false }
|
|
127
|
+
process.off('SIGINT', stop); process.off('SIGTERM', stop)
|
|
128
|
+
}
|
|
129
|
+
const summaries = summarizeAttempts(attempts)
|
|
130
|
+
const prior = summaries.baseline
|
|
131
|
+
const next = summaries.candidate
|
|
132
|
+
const sourceStable = metadata?.source_sha256 === treeHash('src') && metadata?.build_sha256 === treeHash('dist') &&
|
|
133
|
+
metadata?.harness_sha256 === treeHash('test/device/automated/latency') && baselineHash === treeHash(dirname(baseline))
|
|
134
|
+
const passed = !fatal && !interrupted && sourceStable && cleanup.servers && cleanup.fixture && nativeFreshness &&
|
|
135
|
+
attempts.length === 26 && attempts.every(attempt => attempt.success) && prior?.total_ms?.count === 10 && next?.total_ms?.count === 10 &&
|
|
136
|
+
next.physical_reads < prior.physical_reads && next.total_ms.p50 <= prior.total_ms.p50 && next.total_ms.p95 < prior.total_ms.p95
|
|
137
|
+
const report = { passed, fatal, interrupted, unrun_attempts: 26 - attempts.length, sourceStable, cleanup, nativeFreshness,
|
|
138
|
+
metadata, expected_fixture_activity: fixtureActivity, fixture_installed_sha256: fixtureSha, baseline: { source_sha256: baselineSource, build_sha256: baselineHash }, summaries, attempts }
|
|
139
|
+
writeFileSync('docs/benchmarks/end-to-end-latency/android-snapshot-pair.json', JSON.stringify(report, null, 2) + '\n')
|
|
140
|
+
process.exitCode = passed ? 0 : 1
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
main().catch(() => { console.error('Snapshot comparison could not write its report'); process.exitCode = 1 })
|