mobile-debug-mcp 0.35.0 → 0.36.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 +86 -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 +253 -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 +36 -16
- 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/manage/ios.js +2 -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 +244 -0
- package/dist/observe/readiness-service.js +1 -1
- package/dist/observe/readiness.js +4 -4
- 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/handlers/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- package/dist/server/handlers/tool-handlers.js +19 -9
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server-core.js +1 -1
- package/dist/server.js +4 -4
- package/dist/utils/ios/utils.js +3 -0
- package/docs/CHANGELOG.md +23 -1
- package/docs/agent-plugin-installation-verification.md +39 -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 +138 -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/tools/TOOLS.md +2 -2
- package/docs/tools/debugger.md +45 -5
- package/docs/tools/system-traces.md +81 -14
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- 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/manage/ios.ts +2 -0
- 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 +204 -0
- package/src/observe/readiness-service.ts +1 -1
- package/src/observe/readiness.ts +4 -4
- 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/handlers/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- package/src/server/handlers/tool-handlers.ts +20 -9
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server-core.ts +1 -1
- package/src/server.ts +3 -3
- package/src/utils/ios/utils.ts +3 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/fixtures/contracts/tool-definitions.json +192 -32
- 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/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +88 -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/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +131 -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/system-trace.test.ts +12 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SystemTraceRouter } from './system-trace-router.js'
|
|
1
2
|
import { randomUUID } from 'node:crypto'
|
|
2
3
|
import { promises as fs } from 'node:fs'
|
|
3
4
|
import os from 'node:os'
|
|
@@ -6,9 +7,8 @@ import { AndroidTraceNative, TRACE_MAX_BYTES, type TraceCapabilities, type Trace
|
|
|
6
7
|
import { evidenceSessionId } from './evidence.js'
|
|
7
8
|
import { runWithBudget, remainingBudget } from '../utils/operation-budget.js'
|
|
8
9
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
}
|
|
10
|
+
export { TraceError } from './system-trace-error.js'
|
|
11
|
+
import { TraceError } from './system-trace-error.js'
|
|
12
12
|
type State = 'starting' | 'recording' | 'stopping' | 'completed' | 'cancelled' | 'failed'
|
|
13
13
|
type Completion = 'requested' | 'duration_limit' | 'cancelled' | 'error'
|
|
14
14
|
export interface TraceResult {
|
|
@@ -224,4 +224,4 @@ export class SystemTraceSessions {
|
|
|
224
224
|
})
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
|
-
export const systemTraces = new SystemTraceSessions()
|
|
227
|
+
export const systemTraces = new SystemTraceRouter(new SystemTraceSessions())
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { iosCapabilities } from '../../debugger/ios-worker.js'
|
|
1
2
|
import { debuggerResponse } from './debugger-response.js'
|
|
2
3
|
import { classify, type DebuggerDiagnostic } from '../../debugger/diagnostics.js'
|
|
3
4
|
import { debuggerContracts } from '../schemas/debugger-schema.js'
|
|
4
|
-
import { debuggerSessions, debuggerCapabilities } from '../../debugger/sessions.js'
|
|
5
|
+
import { debuggerSessions, iosDebuggerSessions, debuggerCapabilities } from '../../debugger/sessions.js'
|
|
5
6
|
import { DebuggerError, withDebuggerBudget } from '../../debugger/worker.js'
|
|
6
7
|
import { wrapResponse, type ToolCallArgs } from '../common.js'
|
|
7
8
|
function failure(code: string, summary: Record<string, unknown> = {}, diagnostic?: DebuggerDiagnostic, command = '', mode: 'compact' | 'debug' = 'debug') {
|
|
@@ -17,23 +18,41 @@ export async function handleDebugger(name: string, args: ToolCallArgs, signal?:
|
|
|
17
18
|
const parsed = debuggerContracts[name].schema.safeParse(args)
|
|
18
19
|
if (!parsed.success) return failure('INVALID_ARGUMENT')
|
|
19
20
|
const { responseMode, ...input } = parsed.data
|
|
20
|
-
const a = input as { timeoutMs: number; deviceId?: string; appId?: string; processId?: number; debugSessionId?: string }
|
|
21
|
+
const a = input as { platform?: 'android' | 'ios'; timeoutMs: number; deviceId?: string; appId?: string; processId?: number; debugSessionId?: string }
|
|
21
22
|
const command = debuggerContracts[name].command
|
|
23
|
+
const sessions = selectSessions(a)
|
|
22
24
|
try {
|
|
23
25
|
if (signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
24
|
-
const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await
|
|
25
|
-
command === 'attach' ? await
|
|
26
|
-
await
|
|
26
|
+
const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await sessions.capabilities(a.deviceId!, a.appId!, a.processId) :
|
|
27
|
+
command === 'attach' ? await sessions.attach(a.deviceId!, a.appId!, a.processId, a.timeoutMs) :
|
|
28
|
+
await sessions.command(a.debugSessionId!, command, { ...a, signal: command === 'events' ? signal : undefined }, a.timeoutMs))
|
|
27
29
|
if (signal?.aborted && command === 'capabilities') throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
28
|
-
|
|
30
|
+
const payload = sessions === iosDebuggerSessions ? iosDiagnostics(result) : result
|
|
31
|
+
return { ...wrapResponse(debuggerResponse(command, responseMode, payload)), ...(!result.success ? { isError: true } : {}) }
|
|
29
32
|
} catch (e) {
|
|
30
33
|
const error = classify(e, 'unknown', 'DEBUGGER_WORKER_FAILED')
|
|
31
34
|
const code = error.code
|
|
32
35
|
let summary: Partial<ReturnType<typeof debuggerSessions.summary>> = {}
|
|
33
|
-
if (a.debugSessionId) { try { summary =
|
|
34
|
-
|
|
36
|
+
if (a.debugSessionId) { try { summary = sessions.summary(a.debugSessionId) } catch { /* Unknown session */ } }
|
|
37
|
+
const response = failure(code, {
|
|
38
|
+
...(sessions === iosDebuggerSessions ? { capabilities: iosCapabilities } : {}),
|
|
35
39
|
debug_session_id: a.debugSessionId ?? null,
|
|
36
|
-
device: a.deviceId ? { platform: 'android', id: a.deviceId } : null, ...summary
|
|
40
|
+
device: a.deviceId ? { platform: a.platform ?? 'android', id: a.deviceId } : null, ...summary
|
|
37
41
|
}, code === 'DEBUGGER_CLEANUP_FAILED' ? summary.error?.diagnostic : error.diagnostic, command, responseMode)
|
|
42
|
+
if (sessions === iosDebuggerSessions) response.content[0].text = JSON.stringify(iosDiagnostics(JSON.parse(response.content[0].text)))
|
|
43
|
+
return response
|
|
38
44
|
}
|
|
39
45
|
}
|
|
46
|
+
|
|
47
|
+
function selectSessions(a: { platform?: string; debugSessionId?: string }) {
|
|
48
|
+
return a.platform === 'ios' || (a.debugSessionId && iosDebuggerSessions.owns(a.debugSessionId)) ? iosDebuggerSessions : debuggerSessions
|
|
49
|
+
}
|
|
50
|
+
function iosRecovery(d: DebuggerDiagnostic) {
|
|
51
|
+
return { ...d, recovery: d.recovery.replace('JDK 17+ availability, including java, javac and jdk.jdi.', 'Xcode and its Swift-enabled LLDB/Python availability.').replace('JDK compiler and worker source/cache', 'LLDB worker source').replace('Java worker', 'LLDB worker').replace('ADB server', 'simulator') }
|
|
52
|
+
}
|
|
53
|
+
function iosDiagnostics<T extends Record<string, unknown>>(value: T) {
|
|
54
|
+
const result = { ...value }
|
|
55
|
+
const error = value.error as { diagnostic?: DebuggerDiagnostic } | undefined
|
|
56
|
+
return { ...result, ...(error?.diagnostic ? { error: { ...error, diagnostic: iosRecovery(error.diagnostic) } } : {}),
|
|
57
|
+
...(Array.isArray(value.diagnostics) ? { diagnostics: (value.diagnostics as DebuggerDiagnostic[]).map(iosRecovery) } : {}) }
|
|
58
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IosTemplate } from '../../observe/ios-trace/capabilities.js'
|
|
1
2
|
import { systemTraces, TraceError } from '../../observe/system-trace.js'
|
|
2
3
|
import { traceTargetSchema, traceStartSchema, traceControlSchema } from '../schemas/system-trace-schema.js'
|
|
3
4
|
import { resolveTargetDevice } from '../../utils/resolve-device.js'
|
|
@@ -57,10 +58,10 @@ export async function handleSystemTrace(name: string, args: ToolCallArgs) {
|
|
|
57
58
|
const parsed = schema.safeParse(args)
|
|
58
59
|
if (!parsed.success) throw new TraceError('INVALID_ARGUMENT')
|
|
59
60
|
if (name === 'get_system_trace_capabilities' || name === 'start_system_trace') {
|
|
60
|
-
const target = parsed.data as { platform: PlatformArg; deviceId?: string; durationMs?: number }
|
|
61
|
-
if (target.platform
|
|
62
|
-
|
|
63
|
-
return
|
|
61
|
+
const target = parsed.data as { platform: PlatformArg; deviceId?: string; durationMs?: number; appId?: string; template?: IosTemplate }
|
|
62
|
+
if (target.platform === 'android' && (target.appId !== undefined || target.template !== undefined)) throw new TraceError('INVALID_ARGUMENT')
|
|
63
|
+
if (target.platform === 'ios') {
|
|
64
|
+
return handleIosTrace(name, target)
|
|
64
65
|
}
|
|
65
66
|
let device
|
|
66
67
|
try { device = await resolveTargetDevice(target) }
|
|
@@ -77,3 +78,9 @@ export async function handleSystemTrace(name: string, args: ToolCallArgs) {
|
|
|
77
78
|
return { ...wrapResponse(result), ...(['failed', 'cancelled'].includes(result.state) ? { isError: true } : {}) }
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
async function handleIosTrace(name: string, target: { deviceId?: string; appId?: string; durationMs?: number; template?: IosTemplate }) {
|
|
82
|
+
if (!target.deviceId || (name === 'start_system_trace' && !target.appId)) throw new TraceError('INVALID_ARGUMENT')
|
|
83
|
+
if (name === 'get_system_trace_capabilities') return wrapResponse(await systemTraces.iosCapabilities(target.deviceId, target.appId, target.template))
|
|
84
|
+
const result = await systemTraces.start(target.deviceId, target.durationMs, { appId: target.appId!, template: target.template })
|
|
85
|
+
return { ...wrapResponse(result), ...(result.state === 'failed' ? { isError: true } : {}) }
|
|
86
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../interact/index.js'
|
|
1
2
|
import type { DeviceInfo } from '../../types.js'
|
|
2
3
|
import { handleDebugger } from './debugger-handlers.js'
|
|
3
4
|
import { debuggerContracts } from '../schemas/debugger-schema.js'
|
|
4
|
-
import { debuggerSessions } from '../../debugger/sessions.js'
|
|
5
|
+
import { debuggerSessions, iosDebuggerSessions } from '../../debugger/sessions.js'
|
|
5
6
|
import { withDebuggerGuard, guardDispatched } from '../../debugger/guard.js'
|
|
6
7
|
import { TraceError } from '../../observe/system-trace.js'
|
|
7
8
|
import { systemTraceTools } from '../schemas/system-trace-schema.js'
|
|
@@ -81,16 +82,25 @@ for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint
|
|
|
81
82
|
toolHandlers[name] = correlatedHandler(name, toolHandlers[name], name === 'adjust_control', name === 'run_journey')
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
function resolveGuardDevice(name: string, args: ToolCallArgs) {
|
|
86
|
+
const platform = args.platform as 'android' | 'ios' | undefined
|
|
87
|
+
const deviceId = args.deviceId as string | undefined
|
|
88
|
+
const stored = name === 'tap_element' && typeof args.elementId === 'string'
|
|
89
|
+
? ToolsInteract.getResolvedElementTarget(args.elementId, platform, deviceId) : null
|
|
90
|
+
return resolveTargetDevice(stored ?? { platform: platform === 'ios' ? 'ios' : 'android', deviceId })
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
async function dispatchGuarded(device: DeviceInfo, args: ToolCallArgs, handler: ToolHandler, addTiming: (response: ToolCallResult) => ToolCallResult) {
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
const sessions = device.platform === 'ios' ? iosDebuggerSessions : debuggerSessions
|
|
95
|
+
return withResolvedDevice(device, () => withDebuggerGuard(device.platform, device.id, async () => {
|
|
96
|
+
const before = sessions.paused(device.id)
|
|
87
97
|
if (before) return wrapResponse({ success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before })
|
|
88
98
|
let response: ToolCallResult
|
|
89
99
|
try { response = await handler(args) } catch (error) {
|
|
90
|
-
if (!
|
|
100
|
+
if (!sessions.paused(device.id)) throw error
|
|
91
101
|
response = wrapResponse({ success: false })
|
|
92
102
|
}
|
|
93
|
-
const pause =
|
|
103
|
+
const pause = sessions.paused(device.id)
|
|
94
104
|
if (pause) {
|
|
95
105
|
const item = response.content.find(item => item.type === 'text')
|
|
96
106
|
if (item?.text) {
|
|
@@ -106,14 +116,15 @@ async function dispatchGuarded(device: DeviceInfo, args: ToolCallArgs, handler:
|
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
function attachPausedObservation(name: string, args: ToolCallArgs, response: ToolCallResult) {
|
|
109
|
-
if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name)
|
|
119
|
+
if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name)) {
|
|
110
120
|
const item = response.content.find(item => item.type === 'text')
|
|
111
121
|
if (item?.text) {
|
|
112
122
|
try {
|
|
113
123
|
const payload = JSON.parse(item.text)
|
|
114
124
|
const device = payload.device ?? payload.evidence?.device
|
|
115
125
|
const id = device?.id ?? args.deviceId
|
|
116
|
-
const
|
|
126
|
+
const sessions = (device?.platform ?? args.platform) === 'ios' ? iosDebuggerSessions : debuggerSessions
|
|
127
|
+
const pause = id ? sessions.paused(String(id)) : null
|
|
117
128
|
if (pause) item.text = JSON.stringify({ ...payload, debugger: pause })
|
|
118
129
|
} catch { /* Non-JSON observation */ }
|
|
119
130
|
}
|
|
@@ -143,8 +154,8 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}, sign
|
|
|
143
154
|
try {
|
|
144
155
|
if (!systemTraceToolNames.has(name)) validateExpectedState(name, args)
|
|
145
156
|
const guarded = ['start_app', 'terminate_app', 'restart_app', 'reset_app_data', 'install_app', 'build_and_install', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'adjust_control', 'run_journey', 'wait_for_ui', 'wait_for_ui_change', 'wait_for_screen_change', 'expect_screen', 'expect_element_visible', 'expect_state'].includes(name)
|
|
146
|
-
if (guarded &&
|
|
147
|
-
const device = await
|
|
157
|
+
if (guarded && (debuggerSessions.hasActiveSessions() || iosDebuggerSessions.hasActiveSessions())) {
|
|
158
|
+
const device = await resolveGuardDevice(name, args)
|
|
148
159
|
return dispatchGuarded(device, args, handler, addTiming)
|
|
149
160
|
}
|
|
150
161
|
const response = await handler(args)
|
|
@@ -2,21 +2,22 @@ import { z } from 'zod'
|
|
|
2
2
|
const text = z.string().min(1).max(512)
|
|
3
3
|
const integer = z.number().int().safe().positive()
|
|
4
4
|
const timeout = z.number().int().min(100).max(30000)
|
|
5
|
-
const
|
|
5
|
+
const iosUdid = /^[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$/
|
|
6
|
+
const target = { platform: z.enum(['android', 'ios']), deviceId: text, appId: text.regex(/^[A-Za-z][\w-]*(?:\.[A-Za-z][\w-]*)+$/), processId: integer.optional() }
|
|
6
7
|
const session = { debugSessionId: text }
|
|
7
8
|
const stop = { ...session, stopId: text }
|
|
8
9
|
const specs = {
|
|
9
|
-
get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional
|
|
10
|
-
attach_debugger: { fields: target, command: 'attach', description: 'Attach an owned Android JDI session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.' },
|
|
10
|
+
get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional platform debugger prerequisites and matching running debuggable processes; does not attach.' },
|
|
11
|
+
attach_debugger: { fields: target, command: 'attach', description: 'Attach an owned Android JDI or iOS simulator LLDB session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.' },
|
|
11
12
|
get_debugger_state: { fields: session, command: 'state', description: 'Read debugger state, current stop and breakpoint/cleanup status.' },
|
|
12
|
-
set_debugger_breakpoint: { fields: { ...session, className: text.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/), line: integer }, command: 'set', description: 'Set an exact
|
|
13
|
+
set_debugger_breakpoint: { fields: { ...session, className: text.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/).optional(), sourceFile: text.regex(/^\/[^\0\r\n]+$/).optional(), line: integer }, command: 'set', description: 'Set an exact source breakpoint: Android className or iOS absolute sourceFile (exactly one), plus line; unloaded classes/images remain pending. No silent line relocation.' },
|
|
13
14
|
remove_debugger_breakpoint: { fields: { ...session, breakpointId: text }, command: 'remove', description: 'Remove an owned breakpoint; repeated removal succeeds.' },
|
|
14
15
|
wait_for_debugger_event: { fields: { ...session, afterSequence: z.number().int().safe().min(0).optional() }, command: 'events', description: 'Wait for bounded debugger events after a sequence cursor. Empty timeout does not affect execution; overflow is explicit.' },
|
|
15
16
|
get_debugger_stack: { fields: { ...stop, threadId: text, offset: z.number().int().min(0).max(999).optional(), limit: z.number().int().min(1).max(100).optional() }, command: 'stack', description: 'Read bounded stack frames for a stopped thread; IDs expire on resume.' },
|
|
16
|
-
get_debugger_locals: { fields: { ...stop, frameId: text }, command: 'locals', description: 'Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters. No evaluation or field traversal.' },
|
|
17
|
+
get_debugger_locals: { fields: { ...stop, frameId: text }, command: 'locals', description: 'Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters; iOS aggregates return type summaries. No evaluation or field traversal.' },
|
|
17
18
|
step_debugger: { fields: { ...stop, threadId: text, kind: z.enum(['into', 'over', 'out']) }, command: 'step', description: 'Acknowledge one-shot line stepping and resume. Wait for a subsequent stop event separately.' },
|
|
18
19
|
resume_debugger: { fields: stop, command: 'resume', description: 'Resume owned debugger suspension and immediately expire stopped references.' },
|
|
19
|
-
detach_debugger: { fields: session, command: 'detach', description: 'Dispose the owned debugger connection, release owned suspension and remove its
|
|
20
|
+
detach_debugger: { fields: session, command: 'detach', description: 'Dispose the owned debugger connection, release owned suspension and remove its owned transport. Idempotent; retries incomplete cleanup.' }
|
|
20
21
|
}
|
|
21
22
|
// Derive JSON schemas and runtime validation from the same small Zod field vocabulary.
|
|
22
23
|
function jsonField(schema: z.ZodType): Record<string, unknown> {
|
|
@@ -31,12 +32,14 @@ function jsonField(schema: z.ZodType): Record<string, unknown> {
|
|
|
31
32
|
throw new Error('Unsupported debugger field schema')
|
|
32
33
|
}
|
|
33
34
|
export const debuggerContracts = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, {
|
|
34
|
-
...spec, schema: z.object({ ...spec.fields, responseMode: z.enum(['compact', 'debug']).default('debug'), timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict()
|
|
35
|
+
...spec, schema: z.object({ ...spec.fields, responseMode: z.enum(['compact', 'debug']).default('debug'), timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict().refine(a => name !== 'set_debugger_breakpoint' || (('className' in a && a.className !== undefined) !== ('sourceFile' in a && a.sourceFile !== undefined)), { message: 'Exactly one breakpoint source selector is required' }).refine(a => !('platform' in a) || a.platform !== 'ios' || ('deviceId' in a && typeof a.deviceId === 'string' && iosUdid.test(a.deviceId)), { message: 'iOS requires an explicit simulator UDID' })
|
|
35
36
|
}]))
|
|
36
37
|
export const debuggerTools = Object.entries(debuggerContracts).map(([name, spec]) => ({
|
|
37
38
|
name, description: spec.description,
|
|
38
39
|
inputSchema: {
|
|
39
40
|
type: 'object' as const, additionalProperties: false,
|
|
41
|
+
...(['get_debugger_capabilities', 'attach_debugger'].includes(name) ? { allOf: [{ if: { properties: { platform: { const: 'ios' } } }, then: { properties: { deviceId: { pattern: iosUdid.source } } } }] } : {}),
|
|
42
|
+
...(name === 'set_debugger_breakpoint' ? { oneOf: [{ required: ['className'], not: { required: ['sourceFile'] } }, { required: ['sourceFile'], not: { required: ['className'] } }] } : {}),
|
|
40
43
|
required: Object.entries(spec.fields).filter(([, field]) => !field.isOptional()).map(([key]) => key),
|
|
41
44
|
properties: { responseMode: { type: 'string', enum: ['compact', 'debug'], default: 'debug' }, ...Object.fromEntries(Object.entries(spec.fields).map(([key, field]) => [key, jsonField(field)])), timeoutMs: { type: 'integer', minimum: 100, maximum: 30000, default: name === 'wait_for_debugger_event' ? 1000 : 10000 } }
|
|
42
45
|
}
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
const target = { platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional() }
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
const target = { platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(), appId: z.string().regex(/^[a-zA-Z0-9._-]+$/).optional(), template: z.enum(['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs']).optional() }
|
|
3
|
+
const validTarget = (value: { platform: string; deviceId?: string; appId?: string; template?: string }) =>
|
|
4
|
+
value.platform === 'ios' ? !!value.deviceId : value.appId === undefined && value.template === undefined
|
|
5
|
+
export const traceTargetSchema = z.object(target).strict().refine(validTarget)
|
|
6
|
+
export const traceStartSchema = z.object({ ...target, durationMs: z.number().int().min(1000).max(120000).default(30000) }).strict().refine(validTarget).refine(value => value.platform !== 'ios' || !!value.appId)
|
|
5
7
|
export const traceControlSchema = z.object({ traceId: z.string().uuid() }).strict()
|
|
6
|
-
const properties = { platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 } }
|
|
8
|
+
const properties = { platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 }, appId: { type: 'string', minLength: 1, pattern: '^[a-zA-Z0-9._-]+$', description: 'iOS only: running development app bundle identifier; required for iOS start.' }, template: { type: 'string', enum: ['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs'], description: 'iOS only. Default Time Profiler; initial simulator support is Time Profiler only.' } }
|
|
9
|
+
const platformRules = (start: boolean) => ({ oneOf: [
|
|
10
|
+
{ properties: { platform: { const: 'android' } }, not: { anyOf: [{ required: ['appId'] }, { required: ['template'] }] } },
|
|
11
|
+
{ properties: { platform: { const: 'ios' } }, required: start ? ['deviceId', 'appId'] : ['deviceId'] }
|
|
12
|
+
] })
|
|
7
13
|
export const systemTraceTools = [
|
|
8
|
-
{ name: 'get_system_trace_capabilities', description: 'Probe Android Perfetto prerequisites without recording.
|
|
9
|
-
inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties } },
|
|
10
|
-
{ name: 'start_system_trace', description: 'Start bounded Android Perfetto recording, returning an owned trace_id. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard.
|
|
11
|
-
inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties: { ...properties, durationMs: { type: 'integer', minimum: 1000, maximum: 120000, default: 30000 } } } },
|
|
14
|
+
{ name: 'get_system_trace_capabilities', description: 'Probe Android Perfetto or iOS Instruments prerequisites without recording. iOS requires explicit booted simulator deviceId; reports template matrix and unknown profiling permissions.',
|
|
15
|
+
inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties, ...platformRules(false) } },
|
|
16
|
+
{ name: 'start_system_trace', description: 'Start bounded Android Perfetto or iOS simulator Time Profiler recording, returning an owned trace_id after readiness. iOS requires explicit deviceId and running appId. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard. Traces can contain sensitive system metadata, logs and paths; local user-managed retention, no upload.',
|
|
17
|
+
inputSchema: { type: 'object' as const, additionalProperties: false, required: ['platform'], properties: { ...properties, durationMs: { type: 'integer', minimum: 1000, maximum: 120000, default: 30000 } }, ...platformRules(true) } },
|
|
12
18
|
...['get_system_trace_status', 'stop_system_trace', 'cancel_system_trace'].map(name => ({ name,
|
|
13
19
|
description: name === 'get_system_trace_status' ? 'Reconcile an owned trace and retrieve its state; finalise duration-completed capture.' :
|
|
14
|
-
name === 'stop_system_trace' ? 'Stop owned capture gracefully and retrieve a local Perfetto
|
|
20
|
+
name === 'stop_system_trace' ? 'Stop owned capture gracefully and retrieve a local Perfetto file or validated Instruments .trace bundle. Retry after a transfer/cleanup failure.' : 'Stop owned capture and discard partial files. Already completed local artifacts are retained.',
|
|
15
21
|
inputSchema: { type: 'object' as const, additionalProperties: false, required: ['traceId'], properties: { traceId: { type: 'string', format: 'uuid' } } }
|
|
16
22
|
}))
|
|
17
23
|
]
|
package/src/server-core.ts
CHANGED
package/src/server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { debuggerSessions } from './debugger/sessions.js'
|
|
2
|
+
import { debuggerSessions, iosDebuggerSessions } from './debugger/sessions.js'
|
|
3
3
|
import { systemTraces } from './observe/system-trace.js'
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
5
5
|
import { createServer } from './server-core.js'
|
|
@@ -22,8 +22,8 @@ async function shutdown() {
|
|
|
22
22
|
const deadline = setTimeout(() => { console.error('System trace shutdown cleanup timed out; owned device captures remain duration-bounded.'); process.exit(1) }, 10000)
|
|
23
23
|
let exitCode = 0
|
|
24
24
|
try {
|
|
25
|
-
const [remaining, debuggers] = await Promise.all([systemTraces.shutdown(), debuggerSessions.shutdown()])
|
|
26
|
-
if (debuggers.length) { exitCode = 1; console.error('Debugger shutdown cleanup incomplete', debuggers.map(s => s.cleanup)) }
|
|
25
|
+
const [remaining, debuggers, iosDebuggers] = await Promise.all([systemTraces.shutdown(), debuggerSessions.shutdown(), iosDebuggerSessions.shutdown()])
|
|
26
|
+
if ((debuggers.length + iosDebuggers.length)) { exitCode = 1; console.error('Debugger shutdown cleanup incomplete', [...debuggers, ...iosDebuggers].map(s => s.cleanup)) }
|
|
27
27
|
if (remaining.length) {
|
|
28
28
|
exitCode = 1
|
|
29
29
|
console.error('System trace shutdown cleanup incomplete:', remaining.map(s => ({ trace_id: s.trace_id, device: s.device, cleanup: s.cleanup })))
|
package/src/utils/ios/utils.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../../debugger/guard.js'
|
|
1
2
|
import { remainingBudget } from '../operation-budget.js'
|
|
2
3
|
import { execFile, spawn, execSync, spawnSync } from "child_process"
|
|
3
4
|
import { DeviceInfo } from "../../types.js"
|
|
@@ -85,6 +86,7 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
85
86
|
return new Promise((resolve, reject) => {
|
|
86
87
|
// Use spawn for better stream control and consistency with Android implementation
|
|
87
88
|
const budgetMs = remainingBudget()
|
|
89
|
+
checkDebuggerGuard()
|
|
88
90
|
const child = spawn(getXcrunCmd(), args)
|
|
89
91
|
|
|
90
92
|
let stdout = ''
|
|
@@ -132,6 +134,7 @@ export function execCommandWithDiagnostics(args: string[], deviceId: string = "b
|
|
|
132
134
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
|
|
133
135
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
|
|
134
136
|
const timeoutMs = budgetMs || (args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT)
|
|
137
|
+
checkDebuggerGuard()
|
|
135
138
|
const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs })
|
|
136
139
|
const runResult = {
|
|
137
140
|
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
assert.equal((await call('get_app_readiness', { platform: 'ios', deviceId })).readiness.state, 'debugger_paused')
|
|
138
|
+
await remove(bp)
|
|
139
|
+
for (const kind of ['into', 'over', 'out']) {
|
|
140
|
+
const response = await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind })
|
|
141
|
+
assert.equal(response.success, true, JSON.stringify(response)); event = await stop()
|
|
142
|
+
}
|
|
143
|
+
const stale = await call('get_debugger_locals', { ...session(), stopId: originalStop.stop_id, frameId: inspected.frame.frame_id })
|
|
144
|
+
assert.equal(stale.error.code, 'DEBUGGER_STALE_REFERENCE')
|
|
145
|
+
await resume(event)
|
|
146
|
+
await expectUI('Debugger result 42')
|
|
147
|
+
const controller = new AbortController()
|
|
148
|
+
const waiting = call('wait_for_debugger_event', { ...session(), afterSequence: cursor, timeoutMs: 30000 }, controller.signal)
|
|
149
|
+
setTimeout(() => controller.abort(), 100)
|
|
150
|
+
await assert.rejects(waiting)
|
|
151
|
+
assert.equal((await call('get_debugger_state', session())).state, 'running')
|
|
152
|
+
await detach()
|
|
153
|
+
|
|
154
|
+
await attach()
|
|
155
|
+
const before = await breakpoint('BEFORE_AWAIT'), after = await breakpoint('AFTER_AWAIT')
|
|
156
|
+
await tap('Run async')
|
|
157
|
+
event = await stop(); assert.equal(event.location.line, line('BEFORE_AWAIT'))
|
|
158
|
+
expectLocal((await locals(event)).result, 'known', '42')
|
|
159
|
+
await remove(before)
|
|
160
|
+
await resume(event)
|
|
161
|
+
event = await stop(); assert.equal(event.location.line, line('AFTER_AWAIT'))
|
|
162
|
+
expectLocal((await locals(event)).result, 'after', '43')
|
|
163
|
+
await remove(after); await resume(event)
|
|
164
|
+
await expectUI('Async result 44')
|
|
165
|
+
await detach()
|
|
166
|
+
|
|
167
|
+
// Boundary stepping has a distinct evidence result, independent of breakpoint coverage.
|
|
168
|
+
await attach()
|
|
169
|
+
const boundary = await breakpoint('BEFORE_AWAIT')
|
|
170
|
+
await tap('Run async'); event = await stop(); await remove(boundary)
|
|
171
|
+
assert.equal((await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind: 'over' })).success, true)
|
|
172
|
+
event = await stop()
|
|
173
|
+
assert.equal(event.location.source_file, iosDebuggerSource)
|
|
174
|
+
assert.equal(event.location.line, line('AFTER_AWAIT'), 'Advertised async stepping must follow the continuation')
|
|
175
|
+
console.log('ASYNC_BOUNDARY_STEP', JSON.stringify({ expected_line: line('AFTER_AWAIT'), observed: event.location }))
|
|
176
|
+
await detach(); await expectUI('Async result 44')
|
|
177
|
+
|
|
178
|
+
await attach()
|
|
179
|
+
const cancel = await breakpoint('CANCELLED')
|
|
180
|
+
await tap('Start cancellation'); await expectUI('Task running'); await tap('Cancel task')
|
|
181
|
+
event = await stop(); expectLocal((await locals(event)).result, 'cancelled', 'true')
|
|
182
|
+
await remove(cancel); await resume(event); await detach(); await expectUI('Task cancelled')
|
|
183
|
+
|
|
184
|
+
if (process.env.MCP_IOS_DEBUGGER_EXTENDED === '1') {
|
|
185
|
+
await attach(); await breakpoint('BREAK_CALCULATE'); await tap('Calculate'); await stop()
|
|
186
|
+
const deadline = Date.now() + 75000
|
|
187
|
+
let state: any
|
|
188
|
+
do { await new Promise(resolve => setTimeout(resolve, 1000)); state = await call('get_debugger_state', session()) } while (state.state === 'paused' && Date.now() < deadline)
|
|
189
|
+
assert.equal(state.reason, 'pause_limit'); assert.equal(state.cleanup.status, 'complete')
|
|
190
|
+
await expectUI('Debugger result 42')
|
|
191
|
+
}
|
|
192
|
+
await attach()
|
|
193
|
+
await trigger('exit')
|
|
194
|
+
let exited: any
|
|
195
|
+
for (let i = 0; i < 100; i++) {
|
|
196
|
+
exited = await call('get_debugger_state', session())
|
|
197
|
+
if (exited.state === 'disconnected' && exited.cleanup.status === 'complete') break
|
|
198
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
199
|
+
}
|
|
200
|
+
assert.equal(exited.state, 'disconnected'); assert.equal(exited.cleanup.status, 'complete')
|
|
201
|
+
sim('launch', deviceId, iosDebuggerAppId); await expectUI('Debugger ready')
|
|
202
|
+
// Clear the earlier calculate result before proving shutdown recovery.
|
|
203
|
+
await tap('Start cancellation'); await expectUI('Task running'); await tap('Cancel task'); await expectUI('Task cancelled')
|
|
204
|
+
// Closing the server while paused must release its owned suspension.
|
|
205
|
+
await attach(); await breakpoint('BREAK_CALCULATE'); await tap('Calculate'); await stop()
|
|
206
|
+
await client.close()
|
|
207
|
+
const verifier = new Client({ name: 'ios-cleanup-verifier', version: '1.0.0' })
|
|
208
|
+
await verifier.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' }))
|
|
209
|
+
try {
|
|
210
|
+
const response = await verifier.callTool({ name: 'get_ui_tree', arguments: { platform: 'ios', deviceId } })
|
|
211
|
+
const tree = JSON.parse((response.content as any[])[0].text)
|
|
212
|
+
assert.ok(tree.elements.some((e: any) => e.text === 'Debugger result 42'), JSON.stringify(tree))
|
|
213
|
+
} finally { await verifier.close() }
|
|
214
|
+
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() }))
|
|
215
|
+
} finally {
|
|
216
|
+
if (debugSessionId) { try { await call('detach_debugger', session()) } catch {} }
|
|
217
|
+
await client.close().catch(() => {})
|
|
218
|
+
sim('uninstall', deviceId, iosDebuggerAppId)
|
|
219
|
+
await rm(fixture.directory, { recursive: true, force: true })
|
|
220
|
+
}
|
|
221
|
+
}
|