mobile-debug-mcp 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
- 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/cursor/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/dist/contracts/device-readiness.js +1 -0
- 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/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/sessions.js +5 -3
- package/dist/observe/readiness-service.js +21 -9
- package/dist/observe/readiness.js +30 -0
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- 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/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 +9 -5
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +1 -1
- 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 +4 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +25 -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/specs/031-ios-system-traces/plan.md +28 -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 +1 -0
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- 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.md +6 -0
- package/package.json +1 -1
- package/src/contracts/device-readiness.ts +8 -0
- 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/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/sessions.ts +4 -2
- package/src/observe/readiness-service.ts +21 -9
- package/src/observe/readiness.ts +33 -0
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- 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/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 +9 -5
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +1 -1
- 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 +4 -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 +7 -1
- 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 +2 -2
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +51 -15
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/unit/debugger/handle-guards.test.ts +2 -1
- 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-sessions.test.ts +23 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -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 +6 -4
- 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,50 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { evidenceSessionId } from '../../observe/evidence.js'
|
|
3
|
+
import { diagnosticsSchema } from '../schemas/interaction-diagnostics.js'
|
|
4
|
+
import { wrapResponse, type ToolCallArgs } from '../common.js'
|
|
5
|
+
|
|
6
|
+
type ExpiryScheduler = (expire: () => void) => () => void
|
|
7
|
+
const scheduleExpiry: ExpiryScheduler = expire => {
|
|
8
|
+
const timer = setTimeout(expire, 600000)
|
|
9
|
+
timer.unref()
|
|
10
|
+
return () => clearTimeout(timer)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class InteractionDiagnostics {
|
|
14
|
+
private readonly entries = new Map<string, { text: string; completed: number; bytes: number; cancel: () => void }>()
|
|
15
|
+
private bytes = 0
|
|
16
|
+
constructor(private readonly now = () => performance.now(), readonly sessionId = evidenceSessionId(), private readonly schedule: ExpiryScheduler = scheduleExpiry) {}
|
|
17
|
+
|
|
18
|
+
private remove(id: string) {
|
|
19
|
+
const entry = this.entries.get(id)
|
|
20
|
+
if (entry) { this.bytes -= entry.bytes; entry.cancel() }
|
|
21
|
+
this.entries.delete(id)
|
|
22
|
+
}
|
|
23
|
+
private prune() {
|
|
24
|
+
for (const [id, entry] of this.entries) if (this.now() - entry.completed >= 600000) this.remove(id)
|
|
25
|
+
}
|
|
26
|
+
retain(result: unknown) {
|
|
27
|
+
this.prune()
|
|
28
|
+
const resultId = randomUUID(), text = JSON.stringify(result), bytes = Buffer.byteLength(text)
|
|
29
|
+
const ref = { session_id: this.sessionId, result_id: resultId }
|
|
30
|
+
if (bytes > 1024 * 1024) return { ...ref, available: false, reason: 'RESULT_TOO_LARGE' }
|
|
31
|
+
while (this.entries.size >= 100 || this.bytes + bytes > 16 * 1024 * 1024) this.remove(this.entries.keys().next().value!)
|
|
32
|
+
this.entries.set(resultId, { text, completed: this.now(), bytes, cancel: this.schedule(() => this.remove(resultId)) })
|
|
33
|
+
this.bytes += bytes
|
|
34
|
+
return { ...ref, available: true }
|
|
35
|
+
}
|
|
36
|
+
lookup(sessionId: string, resultId: string) {
|
|
37
|
+
this.prune()
|
|
38
|
+
if (sessionId !== this.sessionId) return { success: false, error: { code: 'SESSION_MISMATCH', message: 'Use the session_id returned with this result.' } }
|
|
39
|
+
const entry = this.entries.get(resultId)
|
|
40
|
+
if (!entry) return { success: false, error: { code: 'RESULT_UNAVAILABLE', message: 'Result expired, was evicted, is unknown, or belongs to a previous server process. Do not replay input automatically.' } }
|
|
41
|
+
return { success: true, result: JSON.parse(entry.text) as unknown }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const interactionDiagnostics = new InteractionDiagnostics()
|
|
45
|
+
export async function handleInteractionDiagnostics(args: ToolCallArgs) {
|
|
46
|
+
const parsed = diagnosticsSchema.safeParse(args)
|
|
47
|
+
return wrapResponse(parsed.success ? interactionDiagnostics.lookup(parsed.data.sessionId, parsed.data.resultId) : {
|
|
48
|
+
success: false, error: { code: 'INVALID_ARGUMENT', message: 'Expected only sessionId and resultId UUID strings.' }
|
|
49
|
+
}, false)
|
|
50
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { observeDeviceReadiness } from '../../observe/device-readiness.js'
|
|
2
|
+
import { benchmarkStage } from '../../utils/benchmark-metrics.js'
|
|
1
3
|
import type { z } from 'zod'
|
|
2
4
|
import { debuggerSessions } from '../../debugger/sessions.js'
|
|
3
5
|
import { systemTraces } from '../../observe/system-trace.js'
|
|
4
|
-
import { currentEvidenceCorrelationId, bindSystemTrace, refreshJourneyEvidence, completeJourneyVerification } from '../../observe/evidence.js'
|
|
6
|
+
import { currentEvidenceCorrelationId, bindSystemTrace, withJourneyStepEvidence, refreshJourneyEvidence, completeJourneyVerification, JourneyStepError } from '../../observe/evidence.js'
|
|
5
7
|
import { runWithBudget } from '../../utils/operation-budget.js'
|
|
6
8
|
import { journeySchema } from '../schemas/journey-schema.js'
|
|
7
9
|
import { resolveTargetDevice, withResolvedDevice } from '../../utils/resolve-device.js'
|
|
@@ -33,19 +35,22 @@ async function assertStep(step: AssertionStep, platform: Journey['platform'], de
|
|
|
33
35
|
if (assertion.kind === 'state_equals') return ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: timeoutMs, poll_interval_ms: pollIntervalMs, exact_budget: true })
|
|
34
36
|
return { success: false, failure_code: 'INVALID_ASSERTION' }
|
|
35
37
|
}
|
|
38
|
+
async function observeStep(s: Context, operation: () => Promise<ToolPayload>) {
|
|
39
|
+
return s.input.responseMode === 'compact' ? withJourneyStepEvidence(operation) : operation()
|
|
40
|
+
}
|
|
36
41
|
async function executeStep(s: Context, step: Step, covered: boolean): Promise<ToolPayload> {
|
|
37
42
|
const { platform, responseMode, defaults } = s.input, deviceId = s.deviceId
|
|
38
43
|
try {
|
|
39
44
|
if (step.type === 'tap') return payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: covered ? 'none' : step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
|
|
40
|
-
if (step.type === 'wait') {
|
|
45
|
+
if (step.type === 'wait') return await observeStep(s, async () => {
|
|
41
46
|
const result = (await withObservationContext({ stepId: step.id, purpose: 'target' }, () => ToolsInteract.waitForUIHandler({ selector: step.selector, condition: step.condition ?? 'exists', timeout_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, platform, deviceId })))!
|
|
42
47
|
return { success: result.status === 'success', ...result }
|
|
43
|
-
}
|
|
44
|
-
if (step.type === 'assert') return await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget(step.timeoutMs ?? 5000, () => assertStep(step, platform, deviceId)))
|
|
48
|
+
})
|
|
49
|
+
if (step.type === 'assert') return await observeStep(s, () => withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget(step.timeoutMs ?? 5000, () => assertStep(step, platform, deviceId))))
|
|
45
50
|
if (step.type === 'start_app') return payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
|
|
46
51
|
return { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
|
|
47
52
|
} catch (error) {
|
|
48
|
-
return { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
|
|
53
|
+
return { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error), ...(error instanceof JourneyStepError ? { evidence: error.evidence } : {}) }
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
function lightVerification(s: Context, step: Step, result: ToolPayload, covered: boolean, success: boolean) {
|
|
@@ -68,24 +73,16 @@ function lightVerification(s: Context, step: Step, result: ToolPayload, covered:
|
|
|
68
73
|
}
|
|
69
74
|
s.pendingLightStep = null
|
|
70
75
|
}
|
|
71
|
-
function compactAssertion(result: ToolPayload, success: boolean) {
|
|
72
|
-
const errorCode = typeof result.error === 'object' ? result.error?.code : undefined
|
|
73
|
-
return { success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
|
|
74
|
-
...(errorCode ? { failure_code: errorCode } : {}),
|
|
75
|
-
...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
|
|
76
|
-
...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
|
|
77
|
-
...(result.actual_state ? { observed: result.actual_state } : {}) }
|
|
78
|
-
}
|
|
79
76
|
function coveredByAssertion(s: Context, step: Step, next?: Step) {
|
|
80
77
|
const verification = ('verificationMode' in step ? step.verificationMode : undefined) ?? s.input.defaults?.verificationMode ?? 'full'
|
|
81
78
|
return step.type === 'tap' && s.input.responseMode === 'compact' && verification === 'light' && next?.type === 'assert' && next.assertion.kind === 'element_visible'
|
|
82
79
|
}
|
|
83
80
|
async function runSteps(s: Context) {
|
|
84
|
-
const { platform,
|
|
81
|
+
const { platform, steps } = s.input
|
|
85
82
|
for (let index = 0; index < steps.length; index++) {
|
|
86
83
|
const step = steps[index]
|
|
87
84
|
if (platform === 'android' && debuggerSessions.paused(s.deviceId)) {
|
|
88
|
-
s.results.push({ id: step.id, type: step.type, status: 'failed', success: false, result: { success: false, failure_code: 'DEBUGGER_PAUSED', dispatch_started: false, delivery_status: 'not_delivered' } })
|
|
85
|
+
s.results.push({ id: step.id, type: step.type, status: 'failed', success: false, result: { device_readiness: await observeDeviceReadiness({ platform, id: s.deviceId }), success: false, failure_code: 'DEBUGGER_PAUSED', dispatch_started: false, delivery_status: 'not_delivered' } })
|
|
89
86
|
s.stoppedAt = step.id
|
|
90
87
|
break
|
|
91
88
|
}
|
|
@@ -94,12 +91,13 @@ async function runSteps(s: Context) {
|
|
|
94
91
|
s.results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } })
|
|
95
92
|
continue
|
|
96
93
|
}
|
|
97
|
-
|
|
94
|
+
const result = await (step.type === 'assert' || step.type === 'wait'
|
|
95
|
+
? benchmarkStage(step.type === 'assert' ? 'assertion_ms' : 'wait_ms', () => executeStep(s, step, covered))
|
|
96
|
+
: executeStep(s, step, covered))
|
|
98
97
|
if (platform === 'android' && debuggerSessions.paused(s.deviceId)) { result.success = false; result.failure_code = 'DEBUGGER_PAUSED'; result.retryable = false }
|
|
99
98
|
if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
|
|
100
99
|
const success = !!result?.success
|
|
101
100
|
lightVerification(s, step, result, covered, success)
|
|
102
|
-
if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type)) result = compactAssertion(result, success)
|
|
103
101
|
s.results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - started } })
|
|
104
102
|
if (!success) s.stoppedAt = step.id
|
|
105
103
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
3
|
import { ToolsObserve } from '../../observe/index.js'
|
|
4
|
+
import { benchmarkStage } from '../../utils/benchmark-metrics.js'
|
|
4
5
|
import { withObservationContext } from '../../observe/performance-metrics.js'
|
|
5
6
|
import {
|
|
6
7
|
getBooleanArg,
|
|
@@ -37,7 +38,7 @@ export async function handleCaptureScreenshot(args: ToolCallArgs) {
|
|
|
37
38
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
38
39
|
const deviceId = getStringArg(args, 'deviceId')
|
|
39
40
|
const started = performance.now()
|
|
40
|
-
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
|
|
41
|
+
const res = await benchmarkStage('screenshot_capture_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId }))
|
|
41
42
|
const screenshotMs = performance.now() - started
|
|
42
43
|
const mime = res.screenshot_mime || 'image/png'
|
|
43
44
|
const content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }> = [
|
|
@@ -4,6 +4,7 @@ import type { ToolCallResult } from '../common.js'
|
|
|
4
4
|
// Internal decoded MCP payloads vary by capability. Keep the fields inspected by
|
|
5
5
|
// orchestration explicit while preserving untouched capability-specific fields.
|
|
6
6
|
export interface ToolPayload {
|
|
7
|
+
device_readiness?: import('../../contracts/device-readiness.js').DeviceReadiness
|
|
7
8
|
success?: boolean
|
|
8
9
|
action_id?: string
|
|
9
10
|
action_type?: string
|
|
@@ -25,7 +26,7 @@ export interface ToolPayload {
|
|
|
25
26
|
status?: string
|
|
26
27
|
condition?: ActionConditionResult
|
|
27
28
|
verification?: { mode?: string; status: string }
|
|
28
|
-
evidence?: { action_id?: string }
|
|
29
|
+
evidence?: { action_id?: string | null }
|
|
29
30
|
verification_diagnostic?: { code: string; retryable: boolean }
|
|
30
31
|
observed?: { matched_count?: number | null }
|
|
31
32
|
timing?: TimingBreakdown
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { finalizeCompactInteraction } from './compact-interaction.js'
|
|
2
|
+
import { handleInteractionDiagnostics } from './interaction-diagnostics.js'
|
|
3
|
+
import { observeDeviceReadiness } from '../../observe/device-readiness.js'
|
|
1
4
|
import { ToolsInteract } from '../../interact/index.js'
|
|
2
5
|
import type { DeviceInfo } from '../../types.js'
|
|
3
6
|
import { handleDebugger } from './debugger-handlers.js'
|
|
@@ -41,6 +44,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
|
|
|
41
44
|
if (!parsed.success) throw new Error('INVALID_ARGUMENT: invalid readiness arguments')
|
|
42
45
|
return wrapResponse(await getAppReadiness(parsed.data, true))
|
|
43
46
|
},
|
|
47
|
+
get_interaction_diagnostics: handleInteractionDiagnostics,
|
|
44
48
|
start_app: handleStartApp,
|
|
45
49
|
terminate_app: handleTerminateApp,
|
|
46
50
|
restart_app: handleRestartApp,
|
|
@@ -94,7 +98,7 @@ async function dispatchGuarded(device: DeviceInfo, args: ToolCallArgs, handler:
|
|
|
94
98
|
const sessions = device.platform === 'ios' ? iosDebuggerSessions : debuggerSessions
|
|
95
99
|
return withResolvedDevice(device, () => withDebuggerGuard(device.platform, device.id, async () => {
|
|
96
100
|
const before = sessions.paused(device.id)
|
|
97
|
-
if (before) return wrapResponse({ success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before })
|
|
101
|
+
if (before) return addTiming(wrapResponse({ device_readiness: await observeDeviceReadiness(device), success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before }))
|
|
98
102
|
let response: ToolCallResult
|
|
99
103
|
try { response = await handler(args) } catch (error) {
|
|
100
104
|
if (!sessions.paused(device.id)) throw error
|
|
@@ -107,7 +111,7 @@ async function dispatchGuarded(device: DeviceInfo, args: ToolCallArgs, handler:
|
|
|
107
111
|
const payload = JSON.parse(item.text)
|
|
108
112
|
Object.assign(payload, { success: false, failure_code: 'DEBUGGER_PAUSED', retryable: false,
|
|
109
113
|
dispatch_started: guardDispatched(), delivery_status: payload.success ? 'delivered' : guardDispatched() ? 'unknown' : 'not_delivered',
|
|
110
|
-
verification: { status: 'blocked', reason: 'DEBUGGER_PAUSED' }, debugger: pause })
|
|
114
|
+
verification: { status: 'blocked', reason: 'DEBUGGER_PAUSED' }, device_readiness: await observeDeviceReadiness(device), debugger: pause })
|
|
111
115
|
item.text = JSON.stringify(payload)
|
|
112
116
|
}
|
|
113
117
|
}
|
|
@@ -139,7 +143,7 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}, sign
|
|
|
139
143
|
const started = performance.now()
|
|
140
144
|
const measured: Record<string, number> = {}
|
|
141
145
|
const addTiming = (response: ToolCallResult) => {
|
|
142
|
-
if (!['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'wait_for_ui', 'get_ui_tree', 'capture_screenshot', 'capture_debug_snapshot'].includes(name)) return response
|
|
146
|
+
if (!['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'wait_for_ui', 'get_ui_tree', 'capture_screenshot', 'capture_debug_snapshot'].includes(name)) return finalizeCompactInteraction(name, args, response)
|
|
143
147
|
const item = response.content.find(item => item.type === 'text')
|
|
144
148
|
if (item?.text) {
|
|
145
149
|
try {
|
|
@@ -148,7 +152,7 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}, sign
|
|
|
148
152
|
item.text = JSON.stringify(value)
|
|
149
153
|
} catch { /* Non-JSON text responses retain their original content. */ }
|
|
150
154
|
}
|
|
151
|
-
return response
|
|
155
|
+
return finalizeCompactInteraction(name, args, response)
|
|
152
156
|
}
|
|
153
157
|
return withTiming(measured, async () => {
|
|
154
158
|
try {
|
|
@@ -162,7 +166,7 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}, sign
|
|
|
162
166
|
attachPausedObservation(name, args, response)
|
|
163
167
|
return addTiming(response)
|
|
164
168
|
} catch (error) {
|
|
165
|
-
if (error instanceof TraceError) return { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true }
|
|
169
|
+
if (error instanceof TraceError) return addTiming({ ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true })
|
|
166
170
|
console.error(`Error executing tool ${name}:`, error)
|
|
167
171
|
return addTiming(wrapToolError(name, error))
|
|
168
172
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
|
3
|
+
export const diagnosticsSchema = z.object({ sessionId: z.string().uuid(), resultId: z.string().uuid() }).strict()
|
|
4
|
+
export const interactionDiagnosticsTool: Tool = {
|
|
5
|
+
name: 'get_interaction_diagnostics',
|
|
6
|
+
description: 'Read the retained original interaction or journey result without replaying input or observing the device. Pass diagnostics.session_id/result_id as sessionId/resultId. Session-local memory: ten minutes, 100 results, 16 MiB total, 1 MiB per result. Expired, evicted or restarted results are unavailable. May contain private UI text and errors; compact output is not redaction.',
|
|
7
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['sessionId', 'resultId'], properties: {
|
|
8
|
+
sessionId: { type: 'string', format: 'uuid' }, resultId: { type: 'string', format: 'uuid' }
|
|
9
|
+
} }
|
|
10
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { interactionDiagnosticsTool } from './schemas/interaction-diagnostics.js'
|
|
1
2
|
import { verificationModeJson, responseModeJson } from '../contracts/execution-controls.js'
|
|
2
3
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
|
3
4
|
import { manageTools } from './definitions/manage.js'
|
|
@@ -53,9 +54,10 @@ const baseOrder = [
|
|
|
53
54
|
export const toolDefinitions: Tool[] = [
|
|
54
55
|
...debuggerTools,
|
|
55
56
|
...systemTraceTools,
|
|
57
|
+
interactionDiagnosticsTool,
|
|
56
58
|
...['get_app_readiness', 'wait_for_app_ready'].map(name => ({
|
|
57
59
|
name,
|
|
58
|
-
description: `${name === 'get_app_readiness' ? 'Observe readiness with at most two fresh UI-tree samples.' : 'Wait within a single bounded budget for settled UI evidence.'} Reports loading, transitioning, settled or unknown with evidence and limitations. Managed Android debugger pauses are reported from debugger evidence; unresponsive detection remains unsupported. Settled normalized foreground UI is not proof of app identity, responsiveness or backend completion.`,
|
|
60
|
+
description: `${name === 'get_app_readiness' ? 'Observe readiness with at most two fresh UI-tree samples.' : 'Wait within a single bounded budget for settled UI evidence.'} Reports loading, transitioning, settled or unknown with evidence and limitations. Managed Android and supported iOS simulator debugger pauses are reported from debugger evidence; unresponsive detection remains unsupported. Settled normalized foreground UI is not proof of app identity, responsiveness or backend completion.`,
|
|
59
61
|
inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties: {
|
|
60
62
|
...readinessPropertiesJson,
|
|
61
63
|
timeoutMs: { ...readinessPropertiesJson.timeoutMs, default: name === 'get_app_readiness' ? 15000 : 30000 },
|
|
@@ -70,10 +72,10 @@ for (const tool of toolDefinitions) {
|
|
|
70
72
|
tool.inputSchema.properties ??= {}
|
|
71
73
|
if (['wait_for_ui', 'wait_for_ui_change'].includes(tool.name)) {
|
|
72
74
|
tool.inputSchema.properties.requireReady = { type: 'boolean', default: false }
|
|
73
|
-
tool.description += '\nSet requireReady=true to require both the existing predicate and settled UI readiness, using the same fresh samples and one timeout budget. The readiness summary explains uncertainty. Default predicate semantics are unchanged.'
|
|
75
|
+
tool.description += '\nDevice-readiness blockers prevent readiness success; unknown/unsupported device signals remain explicit.\nSet requireReady=true to require both the existing predicate and settled UI readiness, using the same fresh samples and one timeout budget. The readiness summary explains uncertainty. Default predicate semantics are unchanged.'
|
|
74
76
|
}
|
|
75
77
|
if (tool.name === 'get_ui_tree') tool.description += '\nSuccessful observations include snapshot_session_id and snapshot_revision for same-device conditional input. Compact errors preserve the error and cannot authorize an expectation.'
|
|
76
|
-
if (tool.name === 'run_journey') tool.description += '\nTap steps may include expectedState from a successful same-device get_ui_tree. A failed condition blocks that input, retains fresh condition evidence
|
|
78
|
+
if (tool.name === 'run_journey') tool.description += '\nTap steps may include expectedState from a successful same-device get_ui_tree. A failed condition blocks that input, retains fresh condition evidence through a diagnostics reference in compact mode, and stops subsequent steps. Conditions are not journey defaults or supported on other step types.'
|
|
77
79
|
if (conditionalTools.has(tool.name)) {
|
|
78
80
|
tool.inputSchema.properties.expectedState = expectedStateJson
|
|
79
81
|
tool.inputSchema.properties.platform ??= { type: 'string', enum: ['android'] }
|
|
@@ -84,11 +86,12 @@ for (const tool of toolDefinitions) {
|
|
|
84
86
|
deviceId: { type: 'string', minLength: 1, pattern: '\\S', not: { enum: ['booted', 'default'] } }
|
|
85
87
|
} }
|
|
86
88
|
}]
|
|
87
|
-
tool.description += '\n\nConditional input: optionally supply expectedState { snapshotSessionId, snapshotRevision, timeoutMs? } from a successful get_ui_tree on the same explicit platform/deviceId. A fresh normalized UI-tree check runs immediately before input, even with verification disabled. PRECONDITION_FAILED or PRECONDITION_UNAVAILABLE means no input was dispatched; inspect condition evidence and re-observe before deciding what to do next. Compact failures
|
|
89
|
+
tool.description += '\n\nConditional input: optionally supply expectedState { snapshotSessionId, snapshotRevision, timeoutMs? } from a successful get_ui_tree on the same explicit platform/deviceId. A fresh normalized UI-tree check runs immediately before input, even with verification disabled. PRECONDITION_FAILED or PRECONDITION_UNAVAILABLE means no input was dispatched; inspect condition evidence and re-observe before deciding what to do next. Compact failures summarize the fresh condition evidence; use get_interaction_diagnostics with the returned diagnostics reference for the retained UI tree. This is not atomic with OS input delivery.'
|
|
88
90
|
} else {
|
|
89
91
|
tool.inputSchema.allOf = [...(Array.isArray(tool.inputSchema.allOf) ? tool.inputSchema.allOf : []), { not: { required: ['expectedState'] } }]
|
|
90
92
|
}
|
|
91
93
|
if (['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back'].includes(tool.name)) {
|
|
94
|
+
tool.description += '\nCompact results summarize observations, condition trees, state deltas and readiness probe evidence within a byte budget. Use get_interaction_diagnostics with the returned diagnostics session_id/result_id to retrieve the original result without replaying input. Debug output is unchanged.'
|
|
92
95
|
Object.assign(tool.inputSchema.properties, {
|
|
93
96
|
verificationMode: { ...verificationModeJson, default: 'full' },
|
|
94
97
|
responseMode: { ...responseModeJson, default: 'debug' },
|
|
@@ -111,6 +114,7 @@ for (const tool of toolDefinitions) {
|
|
|
111
114
|
// Correlation is explicit; an omitted reference creates an independent observation.
|
|
112
115
|
for (const tool of toolDefinitions) {
|
|
113
116
|
tool.inputSchema.properties ??= {}
|
|
117
|
+
if (['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'adjust_control', 'get_app_readiness', 'wait_for_app_ready'].includes(tool.name)) tool.description += '\nResults include device_readiness with timestamped device blocker evidence. Android reports supported power/keyguard signals; iOS signals may be unknown or explicitly unsupported. Launch/input dispatch success does not establish app visibility or input readiness. No automatic unlock or wake is performed.'
|
|
114
118
|
if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'read_log_stream'].includes(tool.name)) {
|
|
115
119
|
Object.assign(tool.inputSchema.properties, { actionRef: {
|
|
116
120
|
type: 'object' as const, additionalProperties: false, required: ['sessionId', 'actionId'], properties: {
|
package/src/server-core.ts
CHANGED
|
@@ -11,12 +11,13 @@ import {
|
|
|
11
11
|
import { wrapResponse } from './server/common.js'
|
|
12
12
|
import { toolDefinitions } from './server/tool-definitions.js'
|
|
13
13
|
import { handleToolCall } from './server/handlers/tool-handlers.js'
|
|
14
|
+
import { withBenchmarkRequest, benchmarkStage } from './utils/benchmark-metrics.js'
|
|
14
15
|
|
|
15
16
|
export { wrapResponse, toolDefinitions, handleToolCall }
|
|
16
17
|
|
|
17
18
|
export const serverInfo = {
|
|
18
19
|
name: 'mobile-debug-mcp',
|
|
19
|
-
version: '0.
|
|
20
|
+
version: '0.37.0'
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function createServer() {
|
|
@@ -48,7 +49,9 @@ export function createServer() {
|
|
|
48
49
|
|
|
49
50
|
server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema>, extra) => {
|
|
50
51
|
const { name, arguments: args } = request.params
|
|
51
|
-
|
|
52
|
+
const operation = () => handleToolCall(name, args as Record<string, unknown>, extra.signal)
|
|
53
|
+
return withBenchmarkRequest(name, () => name.startsWith('expect_') || name.startsWith('wait_for_')
|
|
54
|
+
? benchmarkStage(name.startsWith('expect_') ? 'assertion_ms' : 'wait_ms', operation) : operation())
|
|
52
55
|
})
|
|
53
56
|
|
|
54
57
|
return server
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ const server = createServer()
|
|
|
9
9
|
|
|
10
10
|
if (process.env.MOBILE_DEBUG_MCP_STARTUP_HEALTHCHECK === '1') {
|
|
11
11
|
getSystemStatus().then((res) => {
|
|
12
|
-
console.info('[startup] system status summary:', { adb: res.adbAvailable, ios: res.iosAvailable, devices: res.devices, iosDevices: res.iosDevices })
|
|
12
|
+
console.info('[startup] system status summary:', { adb: 'adbAvailable' in res ? res.adbAvailable : false, ios: 'iosAvailable' in res ? res.iosAvailable : false, devices: 'devices' in res ? res.devices : 0, iosDevices: 'iosDevices' in res ? res.iosDevices : 0 })
|
|
13
13
|
}).catch((e) => console.warn('[startup] healthcheck failed:', e instanceof Error ? e.message : String(e)))
|
|
14
14
|
}
|
|
15
15
|
|
package/src/system/android.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { probeInstallation, validAppId, type AppInstallation } from '../utils/app-installation.js'
|
|
1
2
|
import { execSync } from 'child_process'
|
|
2
3
|
import { ensureAdbAvailable } from '../utils/android/utils.js'
|
|
3
4
|
|
|
@@ -10,6 +11,8 @@ export async function checkAndroid() {
|
|
|
10
11
|
let logsAvailable = false
|
|
11
12
|
let envValid = false
|
|
12
13
|
let appInstalled: boolean | undefined = undefined
|
|
14
|
+
let appInstallations: AppInstallation[] = []
|
|
15
|
+
let targets: { id: string; available: boolean }[] = []
|
|
13
16
|
|
|
14
17
|
try {
|
|
15
18
|
const adbCheck = ensureAdbAvailable()
|
|
@@ -21,6 +24,7 @@ export async function checkAndroid() {
|
|
|
21
24
|
try {
|
|
22
25
|
const out = execSync(`${adbCmd} devices -l`, { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
|
|
23
26
|
const state = parseDevices(out, issues)
|
|
27
|
+
targets = out.split('\n').map(line => line.trim().split(/\s+/)).filter(parts => parts[0] && !['List', '*'].includes(parts[0])).map(parts => ({ id: parts[0], available: parts[1] === 'device' }))
|
|
24
28
|
devices = state.devices; deviceStates = state.deviceStates
|
|
25
29
|
|
|
26
30
|
} catch (e: unknown) { console.debug('[get_system_status] adb devices failed: ' + String(e)); issues.push('Failed to list Android devices') }
|
|
@@ -38,12 +42,12 @@ export async function checkAndroid() {
|
|
|
38
42
|
if (!envValid) issues.push('ANDROID_SDK_ROOT/ANDROID_HOME missing and adb not found in PATH')
|
|
39
43
|
|
|
40
44
|
const pkg = process.env.MCP_TARGET_PACKAGE || process.env.MCP_TARGET_APP_ID
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
appInstallations = await Promise.all(targets.map(target => probeInstallation(adbCmd, target.id, pkg, 'android-current-user', adbAvailable && target.available)))
|
|
46
|
+
reportInstallations(appInstallations, pkg, issues)
|
|
47
|
+
appInstalled = installationSummary(appInstallations)
|
|
44
48
|
} catch (e: unknown) { console.debug('[get_system_status] adb availability check failed: ' + String(e)); issues.push('ADB check failed') }
|
|
45
49
|
|
|
46
|
-
return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, issues }
|
|
50
|
+
return { adbAvailable, adbVersion, devices, deviceStates, logsAvailable, envValid, appInstalled, appInstallations, issues }
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
function parseDevices(out: string, issues: string[]) {
|
|
@@ -64,11 +68,14 @@ function parseDevices(out: string, issues: string[]) {
|
|
|
64
68
|
return { devices, deviceStates }
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
|
|
72
|
+
function installationSummary(checks: AppInstallation[]) {
|
|
73
|
+
return checks.length === 1 && checks[0].status !== 'unknown' ? checks[0].status === 'installed' : undefined
|
|
74
|
+
}
|
|
75
|
+
function reportInstallations(checks: AppInstallation[], pkg: string | undefined, issues: string[]) {
|
|
76
|
+
if (pkg !== undefined && !validAppId(pkg)) issues.push('Invalid target app identifier')
|
|
77
|
+
for (const check of checks) {
|
|
78
|
+
if (check.status === 'absent') issues.push(`App ${check.appId} not installed on device ${check.deviceId}`)
|
|
79
|
+
if (check.status === 'unknown' && check.reason !== 'APP_ID_NOT_PROVIDED') issues.push(`Installation check unavailable on device ${check.deviceId}: ${check.reason}`)
|
|
80
|
+
}
|
|
74
81
|
}
|
package/src/system/index.ts
CHANGED
|
@@ -47,7 +47,8 @@ export async function getSystemStatus() {
|
|
|
47
47
|
logsAvailable: androidRaw.logsAvailable,
|
|
48
48
|
envValid: androidRaw.envValid,
|
|
49
49
|
issues,
|
|
50
|
-
appInstalled: androidRaw.appInstalled,
|
|
50
|
+
...(androidRaw.appInstalled === undefined ? {} : { appInstalled: androidRaw.appInstalled }),
|
|
51
|
+
appInstallations: androidRaw.appInstallations,
|
|
51
52
|
iosAvailable: iosRaw.iosAvailable,
|
|
52
53
|
iosDevices: iosRaw.iosDevices,
|
|
53
54
|
gradleJavaHome: gradle.gradleJavaHome,
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface DeviceInfo {
|
|
|
7
7
|
model: string;
|
|
8
8
|
simulator: boolean;
|
|
9
9
|
appInstalled?: boolean;
|
|
10
|
+
appInstallation?: import('./utils/app-installation.js').AppInstallation;
|
|
10
11
|
booted?: boolean;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -355,6 +356,7 @@ export interface ActionConditionResult {
|
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
export interface ActionExecutionResult {
|
|
359
|
+
device_readiness?: import('./contracts/device-readiness.js').DeviceReadiness;
|
|
358
360
|
action_id: string;
|
|
359
361
|
timestamp: string;
|
|
360
362
|
action_type: string;
|
|
@@ -2,7 +2,8 @@ import { DeviceInfo } from "../../types.js"
|
|
|
2
2
|
import { promises as fsPromises } from 'fs'
|
|
3
3
|
import path from 'path'
|
|
4
4
|
export { findInPath, resolveAdbCmd, getAdbCmd, ensureAdbAvailable, getAdbArgs, getAdbTimeout, type SpawnOptionsWithTimeout, execAdb, spawnAdb } from './commands.js'
|
|
5
|
-
import { execAdb } from './commands.js'
|
|
5
|
+
import { execAdb, getAdbCmd } from './commands.js'
|
|
6
|
+
import { installationFields, probeInstallation } from '../app-installation.js'
|
|
6
7
|
export { type GradleEnvOverrides, mergeEnv, prepareGradle } from './gradle.js'
|
|
7
8
|
export { parseBounds, getCenter, parseBooleanAttr, parseNumberAttr, normalizeClassName, inferAndroidRole, buildAndroidSelectorConfidence, buildAndroidSelector, buildAndroidSemantic, isSliderLikeAndroid, extractAndroidState, traverseNode } from './ui-tree.js'
|
|
8
9
|
export { parseLogLine } from './logs.js'
|
|
@@ -82,15 +83,12 @@ export async function listAndroidDevices(appId?: string): Promise<DeviceInfo[]>
|
|
|
82
83
|
execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
|
|
83
84
|
])
|
|
84
85
|
const simulator = simOutput === '1'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled } as DeviceInfo & { appInstalled?: boolean }
|
|
93
|
-
} catch (e: unknown) { console.debug(`[listAndroidDevices] failed gathering metadata for ${serial}: ${String(e)}`); return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false } as DeviceInfo & { appInstalled?: boolean } }
|
|
86
|
+
const check = await probeInstallation(getAdbCmd(), serial, appId, 'android-current-user', deviceLines.find(line => line.split(/\s+/)[0] === serial)?.split(/\s+/)[1] === 'device')
|
|
87
|
+
return { platform: 'android', id: serial, osVersion, model, simulator, ...installationFields(check) } as DeviceInfo
|
|
88
|
+
} catch {
|
|
89
|
+
const check = await probeInstallation(getAdbCmd(), serial, appId, 'android-current-user', false)
|
|
90
|
+
return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, ...installationFields(check) } as DeviceInfo
|
|
91
|
+
}
|
|
94
92
|
}))
|
|
95
93
|
|
|
96
94
|
return infos
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { execCmd } from './exec.js'
|
|
3
|
+
import { remainingBudget } from './operation-budget.js'
|
|
4
|
+
|
|
5
|
+
export interface AppInstallation {
|
|
6
|
+
status: 'installed' | 'absent' | 'unknown'
|
|
7
|
+
appId: string | null
|
|
8
|
+
deviceId: string
|
|
9
|
+
scope: 'android-current-user' | 'ios-simulator'
|
|
10
|
+
checkedAt: string | null
|
|
11
|
+
reason?: 'APP_ID_NOT_PROVIDED' | 'DEVICE_UNAVAILABLE' | 'CHECK_FAILED' | 'UNRECOGNIZED_RESPONSE'
|
|
12
|
+
}
|
|
13
|
+
export function validAppId(appId: string) { return /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(appId) }
|
|
14
|
+
export function installationContext(deviceId: string, appId: string | undefined, scope: AppInstallation['scope']): AppInstallation {
|
|
15
|
+
return { status: 'unknown', appId: appId ?? null, deviceId, scope, checkedAt: null, reason: appId === undefined ? 'APP_ID_NOT_PROVIDED' : 'CHECK_FAILED' }
|
|
16
|
+
}
|
|
17
|
+
export function installationFields(appInstallation: AppInstallation) {
|
|
18
|
+
return { appInstallation, ...(appInstallation.status === 'unknown' ? {} : { appInstalled: appInstallation.status === 'installed' }) }
|
|
19
|
+
}
|
|
20
|
+
export async function probeInstallation(command: string, deviceId: string, appId: string | undefined, scope: AppInstallation['scope'], available = true): Promise<AppInstallation> {
|
|
21
|
+
const result = installationContext(deviceId, appId, scope)
|
|
22
|
+
if (appId === undefined || !validAppId(appId)) return result
|
|
23
|
+
if (!available) return { ...result, reason: 'DEVICE_UNAVAILABLE' }
|
|
24
|
+
try {
|
|
25
|
+
return await runProbe(command, result)
|
|
26
|
+
} catch {
|
|
27
|
+
return { ...result, checkedAt: new Date().toISOString(), reason: 'CHECK_FAILED' }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function runProbe(command: string, context: AppInstallation): Promise<AppInstallation> {
|
|
31
|
+
const android = context.scope === 'android-current-user'
|
|
32
|
+
const started = performance.now()
|
|
33
|
+
const timeout = remainingBudget(5000)
|
|
34
|
+
const args = android ? ['-s', context.deviceId, 'shell', 'pm', 'path', context.appId!] : ['simctl', 'listapps', context.deviceId]
|
|
35
|
+
const output = await execCmd(command, args, { timeout })
|
|
36
|
+
const result = { ...context, checkedAt: new Date().toISOString() }
|
|
37
|
+
if (output.exitCode !== 0) {
|
|
38
|
+
if (android && !output.stdout && !output.stderr) return confirmAndroidAbsence(command, result, Math.max(1, timeout - (performance.now() - started)))
|
|
39
|
+
return { ...result, reason: 'CHECK_FAILED' }
|
|
40
|
+
}
|
|
41
|
+
let status: AppInstallation['status']
|
|
42
|
+
if (android) {
|
|
43
|
+
const lines = output.stdout.trim().split('\n').filter(Boolean)
|
|
44
|
+
if (lines.some(line => !/^package:\/\S+$/.test(line.trim()))) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
|
|
45
|
+
status = lines.length ? 'installed' : 'absent'
|
|
46
|
+
} else {
|
|
47
|
+
try {
|
|
48
|
+
const budget = remainingBudget(Math.max(1, Math.floor(timeout - (performance.now() - started))))
|
|
49
|
+
if (performance.now() - started >= timeout) return { ...result, reason: 'CHECK_FAILED' }
|
|
50
|
+
const json = await new Promise<string>((resolve, reject) => {
|
|
51
|
+
const child = execFile('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '--', '-'], { timeout: budget, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => error ? reject(error) : resolve(stdout))
|
|
52
|
+
child.stdin?.on('error', reject)
|
|
53
|
+
child.stdin?.end(output.stdout)
|
|
54
|
+
})
|
|
55
|
+
const apps: unknown = JSON.parse(json)
|
|
56
|
+
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
|
|
57
|
+
if (Object.values(apps).some(value => !value || typeof value !== 'object' || Array.isArray(value))) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
|
|
58
|
+
status = Object.hasOwn(apps, context.appId!) ? 'installed' : 'absent'
|
|
59
|
+
} catch { return { ...result, reason: 'UNRECOGNIZED_RESPONSE' } }
|
|
60
|
+
}
|
|
61
|
+
const { reason: _reason, ...confirmed } = result
|
|
62
|
+
return { ...confirmed, status }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function confirmAndroidAbsence(command: string, context: AppInstallation, timeout: number): Promise<AppInstallation> {
|
|
66
|
+
const output = await execCmd(command, ['-s', context.deviceId, 'shell', 'pm', 'list', 'packages', context.appId!], { timeout })
|
|
67
|
+
const result = { ...context, checkedAt: new Date().toISOString() }
|
|
68
|
+
if (output.exitCode !== 0) return { ...result, reason: 'CHECK_FAILED' }
|
|
69
|
+
const lines = output.stdout.split('\n').map(line => line.trim()).filter(Boolean)
|
|
70
|
+
if (lines.some(line => !/^package:[A-Za-z0-9_.-]+$/.test(line))) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
|
|
71
|
+
const { reason: _reason, ...confirmed } = result
|
|
72
|
+
return { ...confirmed, status: lines.includes(`package:${context.appId}`) ? 'installed' : 'absent' }
|
|
73
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
interface Span { stage: string; start_ms: number; end_ms: number }
|
|
5
|
+
interface Measurement { id: string; started: number; spans: Span[] }
|
|
6
|
+
const measurement = new AsyncLocalStorage<Measurement>()
|
|
7
|
+
|
|
8
|
+
// Private benchmark diagnostics: no arguments, device IDs or response contents.
|
|
9
|
+
export function recordBenchmarkSpan(stage: string, duration: number) {
|
|
10
|
+
const current = measurement.getStore()
|
|
11
|
+
if (!current) return
|
|
12
|
+
const end = performance.now() - current.started
|
|
13
|
+
current.spans.push({ stage, start_ms: Math.max(0, end - duration), end_ms: end })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function benchmarkSubprocess<T>(operation: () => Promise<T>): Promise<T> {
|
|
17
|
+
return benchmarkStage('subprocess_ms', operation)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function benchmarkStage<T>(stage: string, operation: () => Promise<T>): Promise<T> {
|
|
21
|
+
if (!measurement.getStore()) return operation()
|
|
22
|
+
const started = performance.now()
|
|
23
|
+
try { return await operation() }
|
|
24
|
+
finally { recordBenchmarkSpan(stage, performance.now() - started) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function withBenchmarkRequest<T>(tool: string, operation: () => Promise<T>): Promise<T> {
|
|
28
|
+
if (process.env.MOBILE_DEBUG_MCP_LATENCY_METRICS !== '1') return operation()
|
|
29
|
+
const current: Measurement = { id: randomUUID(), started: performance.now(), spans: [] }
|
|
30
|
+
return measurement.run(current, async () => {
|
|
31
|
+
let completed = false
|
|
32
|
+
try {
|
|
33
|
+
const result = await operation()
|
|
34
|
+
completed = true
|
|
35
|
+
return result
|
|
36
|
+
} finally {
|
|
37
|
+
process.stderr.write(`${JSON.stringify({ event: 'latency_metric', request_id: current.id,
|
|
38
|
+
tool, handler_completed: completed, elapsed_ms: performance.now() - current.started,
|
|
39
|
+
spans: current.spans })}\n`)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
package/src/utils/exec.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
2
2
|
import { remainingBudget } from './operation-budget.js'
|
|
3
3
|
import { spawn } from 'child_process'
|
|
4
|
+
import { benchmarkSubprocess } from './benchmark-metrics.js'
|
|
4
5
|
|
|
5
6
|
export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
|
|
6
7
|
|
|
7
8
|
export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
|
|
9
|
+
return benchmarkSubprocess(() => execute(cmd, args, opts))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function execute(cmd: string, args: string[], opts: ExecOptions): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
|
|
8
13
|
const { env, cwd, shell } = opts
|
|
9
14
|
const timeout = remainingBudget(opts.timeout ?? 0)
|
|
10
15
|
return new Promise((resolve, reject) => {
|
package/src/utils/ios/utils.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installationFields, probeInstallation } from '../app-installation.js'
|
|
1
2
|
import { checkDebuggerGuard } from '../../debugger/guard.js'
|
|
2
3
|
import { remainingBudget } from '../operation-budget.js'
|
|
3
4
|
import { execFile, spawn, execSync, spawnSync } from "child_process"
|
|
@@ -282,16 +283,9 @@ export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
|
|
|
282
283
|
booted: bootedSet.has(device.udid)
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
.then(() => { info.appInstalled = true })
|
|
289
|
-
.catch(() => { info.appInstalled = false })
|
|
290
|
-
.then(() => { out.push(info) })
|
|
291
|
-
checks.push(p)
|
|
292
|
-
} else {
|
|
293
|
-
out.push(info)
|
|
294
|
-
}
|
|
286
|
+
out.push(info)
|
|
287
|
+
checks.push(probeInstallation(getXcrunCmd(), device.udid, appId, 'ios-simulator', info.booted === true)
|
|
288
|
+
.then(check => { Object.assign(info, installationFields(check)) }))
|
|
295
289
|
}
|
|
296
290
|
}
|
|
297
291
|
|