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
|
@@ -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,5 @@
|
|
|
1
|
+
import { installationFields, probeInstallation } from '../app-installation.js'
|
|
2
|
+
import { checkDebuggerGuard } from '../../debugger/guard.js'
|
|
1
3
|
import { remainingBudget } from '../operation-budget.js'
|
|
2
4
|
import { execFile, spawn, execSync, spawnSync } from "child_process"
|
|
3
5
|
import { DeviceInfo } from "../../types.js"
|
|
@@ -85,6 +87,7 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
85
87
|
return new Promise((resolve, reject) => {
|
|
86
88
|
// Use spawn for better stream control and consistency with Android implementation
|
|
87
89
|
const budgetMs = remainingBudget()
|
|
90
|
+
checkDebuggerGuard()
|
|
88
91
|
const child = spawn(getXcrunCmd(), args)
|
|
89
92
|
|
|
90
93
|
let stdout = ''
|
|
@@ -132,6 +135,7 @@ export function execCommandWithDiagnostics(args: string[], deviceId: string = "b
|
|
|
132
135
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
|
|
133
136
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
|
|
134
137
|
const timeoutMs = budgetMs || (args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT)
|
|
138
|
+
checkDebuggerGuard()
|
|
135
139
|
const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs })
|
|
136
140
|
const runResult = {
|
|
137
141
|
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
@@ -279,16 +283,9 @@ export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
|
|
|
279
283
|
booted: bootedSet.has(device.udid)
|
|
280
284
|
}
|
|
281
285
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
.then(() => { info.appInstalled = true })
|
|
286
|
-
.catch(() => { info.appInstalled = false })
|
|
287
|
-
.then(() => { out.push(info) })
|
|
288
|
-
checks.push(p)
|
|
289
|
-
} else {
|
|
290
|
-
out.push(info)
|
|
291
|
-
}
|
|
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)) }))
|
|
292
289
|
}
|
|
293
290
|
}
|
|
294
291
|
|
|
@@ -51,6 +51,8 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
|
|
|
51
51
|
|
|
52
52
|
const deviceContext = new AsyncLocalStorage<DeviceInfo>()
|
|
53
53
|
|
|
54
|
+
export function currentResolvedDevice() { return deviceContext.getStore() }
|
|
55
|
+
|
|
54
56
|
export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
|
|
55
57
|
bindEvidenceDevice(device)
|
|
56
58
|
return deviceContext.run(device, operation)
|
|
@@ -92,7 +94,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
|
|
|
92
94
|
if (prefer === "emulator") candidates = candidates.filter(d => d.simulator)
|
|
93
95
|
|
|
94
96
|
if (appId) {
|
|
95
|
-
const installed = candidates.filter(d => d.appInstalled)
|
|
97
|
+
const installed = candidates.filter(d => d.appInstallation ? d.appInstallation.status === 'installed' && d.appInstallation.appId === appId && d.appInstallation.deviceId === d.id : d.appInstalled === true)
|
|
96
98
|
if (installed.length > 0) candidates = installed
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -117,7 +119,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
|
|
|
117
119
|
return candidates[0]
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }))
|
|
122
|
+
const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled, appInstallation: d.appInstallation }))
|
|
121
123
|
const err = Object.assign(new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`), { devices: list })
|
|
122
124
|
throw err
|
|
123
125
|
}
|
package/src/utils/timing.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { recordBenchmarkSpan } from './benchmark-metrics.js'
|
|
2
3
|
const timings = new AsyncLocalStorage<Record<string, number>>()
|
|
3
4
|
export function withTiming<T>(values: Record<string, number>, operation: () => Promise<T>) { return timings.run(values, operation) }
|
|
4
5
|
export function recordTiming(component: string, milliseconds: number) {
|
|
6
|
+
recordBenchmarkSpan(component, milliseconds)
|
|
5
7
|
const values = timings.getStore()
|
|
6
8
|
if (values) values[component] = (values[component] ?? 0) + milliseconds
|
|
7
9
|
}
|
|
@@ -9,7 +11,6 @@ export async function measure<T>(component: string, operation: () => Promise<T>)
|
|
|
9
11
|
const start = performance.now()
|
|
10
12
|
try { return await operation() }
|
|
11
13
|
finally {
|
|
12
|
-
|
|
13
|
-
if (values) values[component] = (values[component] ?? 0) + performance.now() - start
|
|
14
|
+
recordTiming(component, performance.now() - start)
|
|
14
15
|
}
|
|
15
16
|
}
|
|
@@ -104,6 +104,11 @@ if (!process.env.MCP_DEBUGGER_DEVICE) {
|
|
|
104
104
|
assert.equal(object.value.type, 'java.lang.Object'); assert.equal(typeof object.value.object_id, 'string')
|
|
105
105
|
const readiness = await call('get_app_readiness', { platform: 'android', deviceId })
|
|
106
106
|
assert.equal(readiness.readiness.state, 'debugger_paused')
|
|
107
|
+
assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
108
|
+
assert.equal(readiness.device_readiness.input_ready, false)
|
|
109
|
+
const readyWait = await call('wait_for_app_ready', { platform: 'android', deviceId })
|
|
110
|
+
assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
|
|
111
|
+
assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
107
112
|
for (const name of ['tap', 'wait_for_ui', 'terminate_app', 'run_journey']) {
|
|
108
113
|
const blocked = await call(name, { platform: 'android', deviceId, x: 5, y: 5, appId: debuggerAppId })
|
|
109
114
|
assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED', name)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { readFile, rm, readdir, writeFile, rename } from 'node:fs/promises'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
6
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
7
|
+
import { buildIOSDebuggerFixture, iosDebuggerAppId, iosDebuggerSource } from '../../../fixtures/ios-debugger-app/build.js'
|
|
8
|
+
|
|
9
|
+
if (!process.env.MCP_IOS_DEBUGGER_DEVICE) {
|
|
10
|
+
console.log('UNRUN iOS debugger: select a booted simulator with MCP_IOS_DEBUGGER_DEVICE')
|
|
11
|
+
} else {
|
|
12
|
+
const deviceId = process.env.MCP_IOS_DEBUGGER_DEVICE
|
|
13
|
+
const sim = (...args: string[]) => execFileSync('xcrun', ['simctl', ...args], { encoding: 'utf8', timeout: 30000 }).trim()
|
|
14
|
+
console.log('Building disposable iOS fixture')
|
|
15
|
+
const fixture = buildIOSDebuggerFixture()
|
|
16
|
+
console.log('Fixture built')
|
|
17
|
+
const source = await readFile(iosDebuggerSource, 'utf8')
|
|
18
|
+
const line = (marker: string) => source.split('\n').findIndex(row => row.includes('// ' + marker)) + 1
|
|
19
|
+
const client = new Client({ name: 'ios-debugger-smoke', version: '1.0.0' })
|
|
20
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
21
|
+
transport.stderr?.on('data', data => process.stderr.write(data))
|
|
22
|
+
const call = async (name: string, args: Record<string, unknown> = {}, signal?: AbortSignal) => {
|
|
23
|
+
const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 45000, signal })
|
|
24
|
+
return JSON.parse((response.content as { type: string; text: string }[]).find(item => item.type === 'text')!.text)
|
|
25
|
+
}
|
|
26
|
+
const hash = createHash('sha256')
|
|
27
|
+
const fingerprint = async (directory: string): Promise<void> => {
|
|
28
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
29
|
+
const file = directory + '/' + entry.name
|
|
30
|
+
if (entry.isDirectory()) await fingerprint(file)
|
|
31
|
+
else if (!file.includes('__pycache__')) hash.update(file).update(await readFile(file))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
await fingerprint('dist'); await fingerprint('src/debugger/lldb')
|
|
35
|
+
const serverHash = hash.digest('hex')
|
|
36
|
+
const target = { platform: 'ios', deviceId, appId: iosDebuggerAppId }
|
|
37
|
+
let debugSessionId = '', cursor = 0
|
|
38
|
+
const session = () => ({ debugSessionId })
|
|
39
|
+
const attach = async () => {
|
|
40
|
+
const response = await call('attach_debugger', { ...target, timeoutMs: 30000 })
|
|
41
|
+
assert.equal(response.success, true, JSON.stringify(response))
|
|
42
|
+
assert.equal(response.device.platform, 'ios')
|
|
43
|
+
debugSessionId = response.debug_session_id; cursor = 0
|
|
44
|
+
}
|
|
45
|
+
const breakpoint = async (marker: string) => {
|
|
46
|
+
const response = await call('set_debugger_breakpoint', { ...session(), sourceFile: iosDebuggerSource, line: line(marker) })
|
|
47
|
+
assert.equal(response.success, true, JSON.stringify(response))
|
|
48
|
+
assert.equal(response.result.status, 'resolved', JSON.stringify(response))
|
|
49
|
+
assert.ok(response.result.locations.every((loc: any) => loc.line === line(marker)))
|
|
50
|
+
return response.result.breakpoint_id
|
|
51
|
+
}
|
|
52
|
+
const remove = (breakpointId: string) => call('remove_debugger_breakpoint', { ...session(), breakpointId })
|
|
53
|
+
const stop = async () => {
|
|
54
|
+
for (let i = 0; i < 15; i++) {
|
|
55
|
+
const response = await call('wait_for_debugger_event', { ...session(), afterSequence: cursor, timeoutMs: 1000 })
|
|
56
|
+
assert.equal(response.success, true, JSON.stringify(response)); cursor = response.next_cursor
|
|
57
|
+
const found = response.events.find((event: any) => event.event === 'stopped')
|
|
58
|
+
if (found) { console.log('STOP', JSON.stringify(found)); return found }
|
|
59
|
+
}
|
|
60
|
+
throw new Error('No iOS debugger stop observed')
|
|
61
|
+
}
|
|
62
|
+
const resume = (event: any) => call('resume_debugger', { ...session(), stopId: event.stop_id })
|
|
63
|
+
const locals = async (event: any) => {
|
|
64
|
+
const stack = await call('get_debugger_stack', { ...session(), stopId: event.stop_id, threadId: event.thread_id })
|
|
65
|
+
assert.equal(stack.success, true, JSON.stringify(stack))
|
|
66
|
+
const frame = stack.result.frames.find((f: any) => f.source_file === iosDebuggerSource)
|
|
67
|
+
assert.ok(frame, JSON.stringify(stack))
|
|
68
|
+
const result = await call('get_debugger_locals', { ...session(), stopId: event.stop_id, frameId: frame.frame_id })
|
|
69
|
+
assert.equal(result.success, true, JSON.stringify(result)); console.log('LOCALS', JSON.stringify(result.result))
|
|
70
|
+
return { result: result.result, frame }
|
|
71
|
+
}
|
|
72
|
+
const expectLocal = (result: any, name: string, value: string) => assert.equal(result.locals.find((v: any) => v.name === name)?.value, value, JSON.stringify(result))
|
|
73
|
+
let commandFile = ''
|
|
74
|
+
const trigger = async (command: string) => {
|
|
75
|
+
console.log('TRIGGER', command)
|
|
76
|
+
await writeFile(commandFile + '.next', command)
|
|
77
|
+
await rename(commandFile + '.next', commandFile)
|
|
78
|
+
}
|
|
79
|
+
const tap = async (text: string) => {
|
|
80
|
+
const tree = await call('get_ui_tree', { platform: 'ios', deviceId })
|
|
81
|
+
assert.ok(tree.elements.some((e: any) => e.text === text), JSON.stringify(tree))
|
|
82
|
+
const commands: Record<string, string> = { Calculate: 'calculate', 'Run async': 'async', 'Start cancellation': 'start-cancellation', 'Cancel task': 'cancel' }
|
|
83
|
+
// The installed idb HID backend is unavailable. A fixture-only main-loop
|
|
84
|
+
// command file invokes the same button actions without debugger evaluation.
|
|
85
|
+
await trigger(commands[text])
|
|
86
|
+
}
|
|
87
|
+
const expectUI = async (text: string) => {
|
|
88
|
+
for (let i = 0; i < 15; i++) {
|
|
89
|
+
const tree = await call('get_ui_tree', { platform: 'ios', deviceId })
|
|
90
|
+
if (tree.elements.some((e: any) => e.text === text)) return
|
|
91
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
92
|
+
}
|
|
93
|
+
throw new Error('Missing independent UI state: ' + text)
|
|
94
|
+
}
|
|
95
|
+
const detach = async () => {
|
|
96
|
+
const response = await call('detach_debugger', session())
|
|
97
|
+
assert.equal(response.cleanup.status, 'complete', JSON.stringify(response))
|
|
98
|
+
assert.deepEqual(response.cleanup.remaining_resources, [])
|
|
99
|
+
assert.equal((await call('detach_debugger', session())).cleanup.status, 'complete')
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
sim('install', deviceId, fixture.app)
|
|
103
|
+
sim('launch', deviceId, iosDebuggerAppId)
|
|
104
|
+
commandFile = sim('get_app_container', deviceId, iosDebuggerAppId, 'data') + '/Documents/debugger-command'
|
|
105
|
+
await client.connect(transport)
|
|
106
|
+
const shortProbe = await call('get_debugger_capabilities', { ...target, timeoutMs: 100 })
|
|
107
|
+
assert.equal(shortProbe.error?.code, 'DEBUGGER_TIMEOUT')
|
|
108
|
+
const absent = await call('get_debugger_capabilities', { ...target, appId: 'dev.mobiledebug.absentfixture' })
|
|
109
|
+
assert.equal(absent.supported, false); assert.ok(absent.reasons.includes('PROCESS_NOT_FOUND'))
|
|
110
|
+
const cap = await call('get_debugger_capabilities', target)
|
|
111
|
+
assert.equal(cap.supported, true, JSON.stringify(cap))
|
|
112
|
+
assert.equal(cap.capabilities.async_execution_inspection, true)
|
|
113
|
+
assert.equal(cap.capabilities.async_stepping, true)
|
|
114
|
+
const foreign = await call('attach_debugger', { ...target, processId: process.pid })
|
|
115
|
+
assert.equal(foreign.success, false); assert.equal(foreign.error.code, 'PROCESS_NOT_FOUND')
|
|
116
|
+
await attach()
|
|
117
|
+
const bp = await breakpoint('BREAK_CALCULATE')
|
|
118
|
+
const pending = await call('set_debugger_breakpoint', { ...session(), sourceFile: '/tmp/nonexistent-ios-debugger-source.swift', line: 1 })
|
|
119
|
+
assert.equal(pending.result.status, 'pending'); await remove(pending.result.breakpoint_id)
|
|
120
|
+
const noncode = await call('set_debugger_breakpoint', { ...session(), sourceFile: iosDebuggerSource, line: 9999 })
|
|
121
|
+
assert.equal(noncode.result.status, 'rejected'); await remove(noncode.result.breakpoint_id)
|
|
122
|
+
const storedHandle = await call('wait_for_ui', { platform: 'ios', deviceId, selector: { text: 'Calculate' }, condition: 'exists', timeout_ms: 10000 })
|
|
123
|
+
assert.equal(storedHandle.status, 'success', JSON.stringify(storedHandle))
|
|
124
|
+
assert.ok(storedHandle.element.elementId)
|
|
125
|
+
await tap('Calculate')
|
|
126
|
+
let event = await stop()
|
|
127
|
+
const originalStop = event
|
|
128
|
+
assert.equal(event.location.line, line('BREAK_CALCULATE'))
|
|
129
|
+
const inspected = await locals(event); expectLocal(inspected.result, 'known', '41')
|
|
130
|
+
const guarded = await call('tap', { platform: 'ios', deviceId, x: 10, y: 10 })
|
|
131
|
+
assert.equal(guarded.failure_code, 'DEBUGGER_PAUSED'); assert.equal(guarded.delivery_status, 'not_delivered')
|
|
132
|
+
const storedTap = await call('tap_element', { elementId: storedHandle.element.elementId, verificationMode: 'none', responseMode: 'compact' })
|
|
133
|
+
assert.equal(storedTap.failure_code, 'DEBUGGER_PAUSED', JSON.stringify(storedTap))
|
|
134
|
+
assert.equal(storedTap.delivery_status, 'not_delivered'); assert.equal(storedTap.dispatch_started, false)
|
|
135
|
+
assert.deepEqual(storedTap.debugger.device, { platform: 'ios', id: deviceId })
|
|
136
|
+
console.log('PASS paused stored iOS handle without platform/device hints rejected before dispatch')
|
|
137
|
+
const readiness = await call('get_app_readiness', { platform: 'ios', deviceId })
|
|
138
|
+
assert.equal(readiness.readiness.state, 'debugger_paused')
|
|
139
|
+
assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
140
|
+
assert.equal(readiness.device_readiness.input_ready, false)
|
|
141
|
+
const readyWait = await call('wait_for_app_ready', { platform: 'ios', deviceId })
|
|
142
|
+
assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
|
|
143
|
+
assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
144
|
+
await remove(bp)
|
|
145
|
+
for (const kind of ['into', 'over', 'out']) {
|
|
146
|
+
const response = await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind })
|
|
147
|
+
assert.equal(response.success, true, JSON.stringify(response)); event = await stop()
|
|
148
|
+
}
|
|
149
|
+
const stale = await call('get_debugger_locals', { ...session(), stopId: originalStop.stop_id, frameId: inspected.frame.frame_id })
|
|
150
|
+
assert.equal(stale.error.code, 'DEBUGGER_STALE_REFERENCE')
|
|
151
|
+
await resume(event)
|
|
152
|
+
await expectUI('Debugger result 42')
|
|
153
|
+
const controller = new AbortController()
|
|
154
|
+
const waiting = call('wait_for_debugger_event', { ...session(), afterSequence: cursor, timeoutMs: 30000 }, controller.signal)
|
|
155
|
+
setTimeout(() => controller.abort(), 100)
|
|
156
|
+
await assert.rejects(waiting)
|
|
157
|
+
assert.equal((await call('get_debugger_state', session())).state, 'running')
|
|
158
|
+
await detach()
|
|
159
|
+
|
|
160
|
+
await attach()
|
|
161
|
+
const before = await breakpoint('BEFORE_AWAIT'), after = await breakpoint('AFTER_AWAIT')
|
|
162
|
+
await tap('Run async')
|
|
163
|
+
event = await stop(); assert.equal(event.location.line, line('BEFORE_AWAIT'))
|
|
164
|
+
expectLocal((await locals(event)).result, 'known', '42')
|
|
165
|
+
await remove(before)
|
|
166
|
+
await resume(event)
|
|
167
|
+
event = await stop(); assert.equal(event.location.line, line('AFTER_AWAIT'))
|
|
168
|
+
expectLocal((await locals(event)).result, 'after', '43')
|
|
169
|
+
await remove(after); await resume(event)
|
|
170
|
+
await expectUI('Async result 44')
|
|
171
|
+
await detach()
|
|
172
|
+
|
|
173
|
+
// Boundary stepping has a distinct evidence result, independent of breakpoint coverage.
|
|
174
|
+
await attach()
|
|
175
|
+
const boundary = await breakpoint('BEFORE_AWAIT')
|
|
176
|
+
await tap('Run async'); event = await stop(); await remove(boundary)
|
|
177
|
+
assert.equal((await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind: 'over' })).success, true)
|
|
178
|
+
event = await stop()
|
|
179
|
+
assert.equal(event.location.source_file, iosDebuggerSource)
|
|
180
|
+
assert.equal(event.location.line, line('AFTER_AWAIT'), 'Advertised async stepping must follow the continuation')
|
|
181
|
+
console.log('ASYNC_BOUNDARY_STEP', JSON.stringify({ expected_line: line('AFTER_AWAIT'), observed: event.location }))
|
|
182
|
+
await detach(); await expectUI('Async result 44')
|
|
183
|
+
|
|
184
|
+
await attach()
|
|
185
|
+
const cancel = await breakpoint('CANCELLED')
|
|
186
|
+
await tap('Start cancellation'); await expectUI('Task running'); await tap('Cancel task')
|
|
187
|
+
event = await stop(); expectLocal((await locals(event)).result, 'cancelled', 'true')
|
|
188
|
+
await remove(cancel); await resume(event); await detach(); await expectUI('Task cancelled')
|
|
189
|
+
|
|
190
|
+
if (process.env.MCP_IOS_DEBUGGER_EXTENDED === '1') {
|
|
191
|
+
await attach(); await breakpoint('BREAK_CALCULATE'); await tap('Calculate'); await stop()
|
|
192
|
+
const deadline = Date.now() + 75000
|
|
193
|
+
let state: any
|
|
194
|
+
do { await new Promise(resolve => setTimeout(resolve, 1000)); state = await call('get_debugger_state', session()) } while (state.state === 'paused' && Date.now() < deadline)
|
|
195
|
+
assert.equal(state.reason, 'pause_limit'); assert.equal(state.cleanup.status, 'complete')
|
|
196
|
+
await expectUI('Debugger result 42')
|
|
197
|
+
}
|
|
198
|
+
await attach()
|
|
199
|
+
await trigger('exit')
|
|
200
|
+
let exited: any
|
|
201
|
+
for (let i = 0; i < 100; i++) {
|
|
202
|
+
exited = await call('get_debugger_state', session())
|
|
203
|
+
if (exited.state === 'disconnected' && exited.cleanup.status === 'complete') break
|
|
204
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
205
|
+
}
|
|
206
|
+
assert.equal(exited.state, 'disconnected'); assert.equal(exited.cleanup.status, 'complete')
|
|
207
|
+
sim('launch', deviceId, iosDebuggerAppId); await expectUI('Debugger ready')
|
|
208
|
+
// Clear the earlier calculate result before proving shutdown recovery.
|
|
209
|
+
await tap('Start cancellation'); await expectUI('Task running'); await tap('Cancel task'); await expectUI('Task cancelled')
|
|
210
|
+
// Closing the server while paused must release its owned suspension.
|
|
211
|
+
await attach(); await breakpoint('BREAK_CALCULATE'); await tap('Calculate'); await stop()
|
|
212
|
+
await client.close()
|
|
213
|
+
const verifier = new Client({ name: 'ios-cleanup-verifier', version: '1.0.0' })
|
|
214
|
+
await verifier.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' }))
|
|
215
|
+
try {
|
|
216
|
+
const response = await verifier.callTool({ name: 'get_ui_tree', arguments: { platform: 'ios', deviceId } })
|
|
217
|
+
const tree = JSON.parse((response.content as any[])[0].text)
|
|
218
|
+
assert.ok(tree.elements.some((e: any) => e.text === 'Debugger result 42'), JSON.stringify(tree))
|
|
219
|
+
} finally { await verifier.close() }
|
|
220
|
+
console.log('PASS iOS MCP debugger core/async/cancellation/cleanup', JSON.stringify({ deviceId, server_sha256: serverHash, source_sha256: createHash('sha256').update(source).digest('hex'), xcode: execFileSync('xcodebuild', ['-version'], { encoding: 'utf8' }).trim(), lldb: execFileSync('xcrun', ['lldb', '--version'], { encoding: 'utf8' }).trim() }))
|
|
221
|
+
} finally {
|
|
222
|
+
if (debugSessionId) { try { await call('detach_debugger', session()) } catch {} }
|
|
223
|
+
await client.close().catch(() => {})
|
|
224
|
+
sim('uninstall', deviceId, iosDebuggerAppId)
|
|
225
|
+
await rm(fixture.directory, { recursive: true, force: true })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
4
|
+
|
|
5
|
+
const client = new Client({ name: 'compact-results-smoke', version: '1.0.0' })
|
|
6
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
7
|
+
async function call(name: string, args: Record<string, unknown> = {}) {
|
|
8
|
+
const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 })
|
|
9
|
+
const item = response.content.find(item => item.type === 'text')
|
|
10
|
+
assert(item?.type === 'text')
|
|
11
|
+
return { value: JSON.parse(item.text), bytes: Buffer.byteLength(item.text) }
|
|
12
|
+
}
|
|
13
|
+
async function details(value: Record<string, any>) {
|
|
14
|
+
const result = await call('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id })
|
|
15
|
+
assert.equal(result.value.success, true)
|
|
16
|
+
return result.value.result
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await client.connect(transport)
|
|
20
|
+
const discovery = await call('list_devices')
|
|
21
|
+
for (const platform of ['android', 'ios']) {
|
|
22
|
+
const device = discovery.value.devices.find((d: Record<string, any>) => d.platform === platform && d.simulator && (platform === 'android' || d.booted))
|
|
23
|
+
assert(device, `A booted ${platform} simulator/emulator is required`)
|
|
24
|
+
const target = { platform, deviceId: device.id }, appId = platform === 'android' ? 'com.android.settings' : 'com.apple.Preferences'
|
|
25
|
+
const none = await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' })
|
|
26
|
+
assert.equal(none.value.success, true)
|
|
27
|
+
assert.equal(none.value.verification.status, 'not_requested')
|
|
28
|
+
assert.equal(none.value.observed_state.status, 'unknown')
|
|
29
|
+
assert.ok(none.bytes <= 1280 + Buffer.byteLength(device.id))
|
|
30
|
+
assert.equal((await details(none.value)).evidence.action_id, none.value.action_id)
|
|
31
|
+
const observed = await call('start_app', { ...target, appId, verificationMode: 'light', verificationTimeoutMs: 30000, responseMode: 'compact' })
|
|
32
|
+
assert.equal(observed.value.success, true)
|
|
33
|
+
assert.equal(observed.value.observed_state.status, 'observed', JSON.stringify(observed.value))
|
|
34
|
+
assert.ok(observed.bytes <= 1280 + Buffer.byteLength(device.id))
|
|
35
|
+
const blocked = await call('tap_element', { ...target, selector: { text: '__mcp_absent_compact_fixture__' }, verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 1000 })
|
|
36
|
+
assert.equal(blocked.value.success, false)
|
|
37
|
+
assert.equal(blocked.value.dispatch_started, false)
|
|
38
|
+
assert.equal(blocked.value.observed_state.status, 'unknown')
|
|
39
|
+
assert.equal((await details(blocked.value)).success, false)
|
|
40
|
+
const tree = await call('get_ui_tree', target)
|
|
41
|
+
const element = tree.value.elements?.find((e: Record<string, any>) => e.visible && typeof e.text === 'string' && e.text.trim())
|
|
42
|
+
assert(element, 'Settings must expose a visible text target')
|
|
43
|
+
const journey = await call('run_journey', { ...target, defaults: { verificationMode: 'none' }, steps: [
|
|
44
|
+
{ id: 'open', type: 'start_app', appId },
|
|
45
|
+
{ id: 'observe', type: 'assert', assertion: { kind: 'element_visible', selector: { text: element.text } }, timeoutMs: 15000 }
|
|
46
|
+
] })
|
|
47
|
+
assert.equal(journey.value.success, true, JSON.stringify(journey.value))
|
|
48
|
+
assert.equal(journey.value.steps[1].result.observed_state.status, 'observed')
|
|
49
|
+
const retainedJourney = await details(journey.value)
|
|
50
|
+
const retainedStep = await details(journey.value.steps[0].result)
|
|
51
|
+
assert.equal(retainedJourney.steps[0].result.evidence.action_id, retainedStep.evidence.action_id)
|
|
52
|
+
assert.ok(retainedStep.evidence.dispatch_completed_at_ms)
|
|
53
|
+
console.log(JSON.stringify({ status: 'PASS', platform, device: device.id, os: device.osVersion, server: client.getServerVersion(),
|
|
54
|
+
bytes: { none: none.bytes, observed: observed.bytes, blocked: blocked.bytes, journey: journey.bytes },
|
|
55
|
+
observed: observed.value.observed_state, journeyAssertion: journey.value.steps[1].result.observed_state,
|
|
56
|
+
checks: ['dispatch-only unknown', 'fresh post-observation', 'no-dispatch failure', 'journey', 'read-only original/child lookup'], cleanup: 'Settings remains foreground; no app data/settings changed' }))
|
|
57
|
+
}
|
|
58
|
+
} finally { await client.close() }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
export function command(command: string, args: string[], timeout = 30000) {
|
|
8
|
+
return execFileSync(command, args, { encoding: 'utf8', timeout, killSignal: 'SIGKILL', stdio: 'pipe' }).trim()
|
|
9
|
+
}
|
|
10
|
+
export function fixturePresent(adb: (...args: string[]) => string, app: string) {
|
|
11
|
+
return adb('shell', 'pm', 'list', 'packages', app).split('\n').includes(`package:${app}`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cleanupFixture(adb: (...args: string[]) => string, app: string, remove: boolean) {
|
|
15
|
+
if (!fixturePresent(adb, app)) return false
|
|
16
|
+
adb('shell', 'am', 'force-stop', app)
|
|
17
|
+
if (!remove) return false
|
|
18
|
+
adb('uninstall', app)
|
|
19
|
+
if (fixturePresent(adb, app)) throw new Error('FIXTURE_REMOVAL_FAILED')
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
24
|
+
export function treeHash(root: string): string {
|
|
25
|
+
const hash = createHash('sha256')
|
|
26
|
+
const visit = (directory: string) => {
|
|
27
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
28
|
+
const file = path.join(directory, entry.name)
|
|
29
|
+
if (entry.isDirectory()) visit(file)
|
|
30
|
+
else if (entry.isFile()) { hash.update(path.relative(root, file)); hash.update(readFileSync(file)) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
visit(root)
|
|
34
|
+
return hash.digest('hex')
|
|
35
|
+
}
|
|
36
|
+
function version(tool: string, args: string[]) {
|
|
37
|
+
try { return command(tool, args).split('\n').filter(line => !/Installed as/.test(line)).slice(0, 3).join('\n') }
|
|
38
|
+
catch { return 'unavailable' }
|
|
39
|
+
}
|
|
40
|
+
export function environment(deviceId: string, apk: string) {
|
|
41
|
+
const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
|
|
42
|
+
const connected = command('adb', ['devices']).split('\n').filter(line => /\sdevice$/.test(line.trim()))
|
|
43
|
+
return {
|
|
44
|
+
commit: command('git', ['rev-parse', 'HEAD']),
|
|
45
|
+
working_tree: command('git', ['status', '--porcelain', '--untracked-files=all']),
|
|
46
|
+
source_sha256: treeHash('src'), build_sha256: treeHash('dist'), harness_sha256: treeHash('test/device/automated/latency'), fixture_sha256: sha256(apk),
|
|
47
|
+
package_version: JSON.parse(readFileSync('package.json', 'utf8')).version,
|
|
48
|
+
node: process.version, host: { platform: os.platform(), release: os.release(), arch: os.arch() },
|
|
49
|
+
platform: 'android', model: adb('shell', 'getprop', 'ro.product.model'),
|
|
50
|
+
discovery_topology: { android_emulators: connected.filter(line => line.startsWith('emulator-')).length,
|
|
51
|
+
android_other_devices: connected.filter(line => !line.startsWith('emulator-')).length },
|
|
52
|
+
os: adb('shell', 'getprop', 'ro.build.version.release'), api: adb('shell', 'getprop', 'ro.build.version.sdk'),
|
|
53
|
+
avd: adb('emu', 'avd', 'name').split('\n')[0], display: adb('shell', 'wm', 'size'), density: adb('shell', 'wm', 'density'),
|
|
54
|
+
tool_versions: { adb: version('adb', ['version']), javac: version(process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', 'javac') : 'javac', ['--version']), android_build_tools: '36.0.0',
|
|
55
|
+
emulator: version(path.join(process.env.ANDROID_HOME ?? path.join(os.homedir(), 'Library/Android/sdk'), 'emulator/emulator'), ['-version']),
|
|
56
|
+
android_compile_platform: '36', xcode: version('xcodebuild', ['-version']), idb: version('idb', ['--version']) },
|
|
57
|
+
timeouts: { setup_ms: 30000, call_ms: 300000, action_ms: 30000, wait_ms: 10000, verification_ms: 60000,
|
|
58
|
+
adb_ms: Number(process.env.MCP_ADB_TIMEOUT ?? 20000) },
|
|
59
|
+
ios: { journey: 'blocked: shared #89 fixture unavailable', observations: 'blocked: idb executable unavailable and no booted simulator at investigation', physical_device: 'excluded' }
|
|
60
|
+
}
|
|
61
|
+
}
|