mobile-debug-mcp 0.33.0 → 0.34.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/.github/workflows/ci.yml +21 -0
- package/README.md +7 -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 +4 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/index.js +16 -0
- package/dist/observe/evidence.js +9 -2
- package/dist/observe/readiness-service.js +9 -0
- package/dist/observe/readiness.js +15 -8
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +1 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +6 -1
- package/dist/server/tool-handlers.js +203 -96
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +2 -0
- package/dist/utils/resolve-device.js +2 -0
- package/docs/CHANGELOG.md +4 -0
- package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/tools/TOOLS.md +3 -1
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +5 -2
- package/docs/tools/observe.md +12 -5
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/index.ts +16 -15
- package/src/observe/evidence.ts +9 -2
- package/src/observe/readiness-service.ts +5 -0
- package/src/observe/readiness.ts +12 -6
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +1 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +6 -1
- package/src/server/tool-handlers.ts +92 -7
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/utils/exec.ts +2 -0
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +2 -0
- package/src/utils/resolve-device.ts +2 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/fixtures/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -0
- package/test/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -0
- package/test/unit/observe/readiness.test.ts +3 -1
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/worker/DebuggerWorker.java +160 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { handleDebugger } from './debugger-handlers.js'
|
|
2
|
+
import { debuggerContracts } from './debugger-schema.js'
|
|
3
|
+
import { debuggerSessions } from '../debugger/sessions.js'
|
|
4
|
+
import { withDebuggerGuard, guardDispatched } from '../debugger/guard.js'
|
|
5
|
+
import { systemTraces, TraceError } from '../observe/system-trace.js'
|
|
6
|
+
import { systemTraceTools, traceTargetSchema, traceStartSchema, traceControlSchema } from './system-trace-schema.js'
|
|
7
|
+
import { currentEvidenceCorrelationId, bindSystemTrace, withEvidence, currentEvidenceActionId, validateActionRef, markEvidenceVerification, refreshJourneyEvidence, completeJourneyVerification } from '../observe/evidence.js'
|
|
2
8
|
import { measure, withTiming } from '../utils/timing.js'
|
|
3
9
|
import { randomUUID } from 'node:crypto'
|
|
4
10
|
import { runWithBudget } from '../utils/operation-budget.js'
|
|
@@ -41,6 +47,8 @@ import {
|
|
|
41
47
|
wrapToolError
|
|
42
48
|
} from './common.js'
|
|
43
49
|
|
|
50
|
+
const systemTraceToolNames = new Set(systemTraceTools.map(tool => tool.name))
|
|
51
|
+
|
|
44
52
|
type PlatformArg = 'android' | 'ios'
|
|
45
53
|
type ProjectTypeArg = 'native' | 'kmp' | 'react-native' | 'flutter'
|
|
46
54
|
type ExpectElementSelectorArg = { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }
|
|
@@ -86,7 +94,7 @@ function correlatedHandler(name: string, handler: ToolHandler, action = false, j
|
|
|
86
94
|
const ref = validateActionRef(args.actionRef)
|
|
87
95
|
const { result, evidence } = await withEvidence({ action, journey, ref }, async () => {
|
|
88
96
|
try { return await handler(args) }
|
|
89
|
-
catch (error) { return wrapToolError(name, error) }
|
|
97
|
+
catch (error) { return error instanceof TraceError ? { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true } : wrapToolError(name, error) }
|
|
90
98
|
})
|
|
91
99
|
const item = result.content.find(item => item.type === 'text')
|
|
92
100
|
if (item?.text) {
|
|
@@ -612,13 +620,22 @@ async function handleRunJourney(args: ToolCallArgs) {
|
|
|
612
620
|
const steps = parsed.data.steps as Array<Record<string, any>>
|
|
613
621
|
const device = await resolveTargetDevice({ platform, deviceId: parsed.data.deviceId })
|
|
614
622
|
const deviceId = device.id
|
|
615
|
-
|
|
623
|
+
const finishTrace = parsed.data.systemTraceId
|
|
624
|
+
? await systemTraces.link(parsed.data.systemTraceId, device, currentEvidenceCorrelationId()!) : undefined
|
|
625
|
+
if (parsed.data.systemTraceId) bindSystemTrace(parsed.data.systemTraceId)
|
|
626
|
+
try {
|
|
627
|
+
return await withResolvedDevice(device, async () => {
|
|
616
628
|
const started = performance.now()
|
|
617
629
|
const results: any[] = []
|
|
618
630
|
let stoppedAt: string | null = null
|
|
619
631
|
let pendingLightStep: number | null = null
|
|
620
632
|
for (let index = 0; index < steps.length; index++) {
|
|
621
633
|
const step = steps[index]
|
|
634
|
+
if (platform === 'android' && debuggerSessions.paused(deviceId)) {
|
|
635
|
+
results.push({ id: step.id, type: step.type, status: 'failed', success: false, result: { success: false, failure_code: 'DEBUGGER_PAUSED', dispatch_started: false, delivery_status: 'not_delivered' } })
|
|
636
|
+
stoppedAt = step.id as string
|
|
637
|
+
break
|
|
638
|
+
}
|
|
622
639
|
const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full'
|
|
623
640
|
const next = steps[index + 1]
|
|
624
641
|
const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
|
|
@@ -653,6 +670,7 @@ async function handleRunJourney(args: ToolCallArgs) {
|
|
|
653
670
|
} catch (error) {
|
|
654
671
|
result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
|
|
655
672
|
}
|
|
673
|
+
if (platform === 'android' && debuggerSessions.paused(deviceId)) { result.success = false; result.failure_code = 'DEBUGGER_PAUSED'; result.retryable = false }
|
|
656
674
|
if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
|
|
657
675
|
const success = !!result?.success
|
|
658
676
|
if (lightCoveredByAssertion) {
|
|
@@ -691,8 +709,9 @@ async function handleRunJourney(args: ToolCallArgs) {
|
|
|
691
709
|
if (key !== 'total_ms' && typeof value === 'number') componentTiming[key] = (componentTiming[key] ?? 0) + value
|
|
692
710
|
}
|
|
693
711
|
refreshJourneyEvidence(results)
|
|
694
|
-
return wrapResponse({ success: stoppedAt === null, stopped_at_step_id: stoppedAt, steps: results, ...(failureSnapshot ? { failure_snapshot: failureSnapshot } : {}), timing: responseMode === 'compact' ? { total_ms: performance.now() - started } : { ...componentTiming, total_ms: performance.now() - started } }, responseMode === 'debug')
|
|
712
|
+
return wrapResponse({ ...(finishTrace ? { system_trace: await finishTrace() } : {}), success: stoppedAt === null, stopped_at_step_id: stoppedAt, steps: results, ...(failureSnapshot ? { failure_snapshot: failureSnapshot } : {}), timing: responseMode === 'compact' ? { total_ms: performance.now() - started } : { ...componentTiming, total_ms: performance.now() - started } }, responseMode === 'debug')
|
|
695
713
|
})
|
|
714
|
+
} finally { await finishTrace?.() }
|
|
696
715
|
}
|
|
697
716
|
|
|
698
717
|
async function executeSwipe(args: ToolCallArgs) {
|
|
@@ -841,7 +860,33 @@ async function handleGetNetworkActivity(args: ToolCallArgs) {
|
|
|
841
860
|
return wrapResponse(result)
|
|
842
861
|
}
|
|
843
862
|
|
|
863
|
+
async function handleSystemTrace(name: string, args: ToolCallArgs) {
|
|
864
|
+
const schema = name === 'start_system_trace' ? traceStartSchema : name === 'get_system_trace_capabilities' ? traceTargetSchema : traceControlSchema
|
|
865
|
+
const parsed = schema.safeParse(args)
|
|
866
|
+
if (!parsed.success) throw new TraceError('INVALID_ARGUMENT')
|
|
867
|
+
if (name === 'get_system_trace_capabilities' || name === 'start_system_trace') {
|
|
868
|
+
const target = parsed.data as { platform: PlatformArg; deviceId?: string; durationMs?: number }
|
|
869
|
+
if (target.platform !== 'android') {
|
|
870
|
+
if (name === 'start_system_trace') throw new TraceError('UNSUPPORTED_PLATFORM')
|
|
871
|
+
return wrapResponse({ supported: false, backend: 'perfetto', device: null, prerequisites: {}, reasons: ['UNSUPPORTED_PLATFORM'], enabled_sources: [], unavailable_categories: [], limitations: ['Android only'] })
|
|
872
|
+
}
|
|
873
|
+
let device
|
|
874
|
+
try { device = await resolveTargetDevice(target) }
|
|
875
|
+
catch {
|
|
876
|
+
if (name === 'start_system_trace') throw new TraceError('TRACE_UNSUPPORTED')
|
|
877
|
+
return wrapResponse({ supported: false, backend: 'perfetto', device: null, prerequisites: { device: false }, reasons: ['DEVICE_UNAVAILABLE'], enabled_sources: [], unavailable_categories: [], limitations: ['An authorised Android device is required'] })
|
|
878
|
+
}
|
|
879
|
+
if (name === 'get_system_trace_capabilities') return wrapResponse(await systemTraces.capabilities(device.id))
|
|
880
|
+
const result = await systemTraces.start(device.id, target.durationMs)
|
|
881
|
+
return { ...wrapResponse(result), ...(result.state === 'failed' ? { isError: true } : {}) }
|
|
882
|
+
}
|
|
883
|
+
const operation = name === 'stop_system_trace' ? 'stop' : name === 'cancel_system_trace' ? 'cancel' : 'status'
|
|
884
|
+
const result = await systemTraces.control((parsed.data as { traceId: string }).traceId, operation)
|
|
885
|
+
return { ...wrapResponse(result), ...(['failed', 'cancelled'].includes(result.state) ? { isError: true } : {}) }
|
|
886
|
+
}
|
|
887
|
+
|
|
844
888
|
export const toolHandlers: Record<string, ToolHandler> = {
|
|
889
|
+
...Object.fromEntries(systemTraceTools.map(({ name }) => [name, (args: ToolCallArgs) => handleSystemTrace(name, args)])),
|
|
845
890
|
get_app_readiness: async args => {
|
|
846
891
|
const parsed = readinessSchema.safeParse(args)
|
|
847
892
|
if (!parsed.success) throw new Error('INVALID_ARGUMENT: invalid readiness arguments')
|
|
@@ -893,7 +938,8 @@ for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint
|
|
|
893
938
|
toolHandlers[name] = correlatedHandler(name, toolHandlers[name], name === 'adjust_control', name === 'run_journey')
|
|
894
939
|
}
|
|
895
940
|
|
|
896
|
-
export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
|
|
941
|
+
export async function handleToolCall(name: string, args: ToolCallArgs = {}, signal?: AbortSignal) {
|
|
942
|
+
if (Object.hasOwn(debuggerContracts, name)) return handleDebugger(name, args, signal)
|
|
897
943
|
const handler = toolHandlers[name]
|
|
898
944
|
if (!handler) throw new Error(`Unknown tool: ${name}`)
|
|
899
945
|
|
|
@@ -913,9 +959,48 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
|
|
|
913
959
|
}
|
|
914
960
|
return withTiming(measured, async () => {
|
|
915
961
|
try {
|
|
916
|
-
validateExpectedState(name, args)
|
|
917
|
-
|
|
962
|
+
if (!systemTraceToolNames.has(name)) validateExpectedState(name, args)
|
|
963
|
+
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)
|
|
964
|
+
if (guarded && args.platform !== 'ios' && debuggerSessions.hasActiveSessions()) {
|
|
965
|
+
const device = await resolveTargetDevice({ platform: 'android', deviceId: args.deviceId as string | undefined })
|
|
966
|
+
return withResolvedDevice(device, () => withDebuggerGuard('android', device.id, async () => {
|
|
967
|
+
const before = debuggerSessions.paused(device.id)
|
|
968
|
+
if (before) return wrapResponse({ success: false, failure_code: 'DEBUGGER_PAUSED', delivery_status: 'not_delivered', dispatch_started: false, debugger: before })
|
|
969
|
+
let response: ToolCallResult
|
|
970
|
+
try { response = await handler(args) } catch (error) {
|
|
971
|
+
if (!debuggerSessions.paused(device.id)) throw error
|
|
972
|
+
response = wrapResponse({ success: false })
|
|
973
|
+
}
|
|
974
|
+
const pause = debuggerSessions.paused(device.id)
|
|
975
|
+
if (pause) {
|
|
976
|
+
const item = response.content.find(item => item.type === 'text')
|
|
977
|
+
if (item?.text) {
|
|
978
|
+
const payload = JSON.parse(item.text)
|
|
979
|
+
Object.assign(payload, { success: false, failure_code: 'DEBUGGER_PAUSED', retryable: false,
|
|
980
|
+
dispatch_started: guardDispatched(), delivery_status: payload.success ? 'delivered' : guardDispatched() ? 'unknown' : 'not_delivered',
|
|
981
|
+
verification: { status: 'blocked', reason: 'DEBUGGER_PAUSED' }, debugger: pause })
|
|
982
|
+
item.text = JSON.stringify(payload)
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return addTiming(response)
|
|
986
|
+
}))
|
|
987
|
+
}
|
|
988
|
+
const response = await handler(args)
|
|
989
|
+
if (['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'get_current_screen', 'read_log_stream'].includes(name) && args.platform !== 'ios') {
|
|
990
|
+
const item = response.content.find(item => item.type === 'text')
|
|
991
|
+
if (item?.text) {
|
|
992
|
+
try {
|
|
993
|
+
const payload = JSON.parse(item.text)
|
|
994
|
+
const device = payload.device ?? payload.evidence?.device
|
|
995
|
+
const id = device?.id ?? args.deviceId
|
|
996
|
+
const pause = id && device?.platform !== 'ios' ? debuggerSessions.paused(String(id)) : null
|
|
997
|
+
if (pause) item.text = JSON.stringify({ ...payload, debugger: pause })
|
|
998
|
+
} catch { /* Non-JSON observation */ }
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return addTiming(response)
|
|
918
1002
|
} catch (error) {
|
|
1003
|
+
if (error instanceof TraceError) return { ...wrapResponse({ error: { tool: name, code: error.code, message: error.message } }), isError: true }
|
|
919
1004
|
console.error(`Error executing tool ${name}:`, error)
|
|
920
1005
|
return addTiming(wrapToolError(name, error))
|
|
921
1006
|
}
|
package/src/server-core.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
|
|
|
16
16
|
|
|
17
17
|
export const serverInfo = {
|
|
18
18
|
name: 'mobile-debug-mcp',
|
|
19
|
-
version: '0.
|
|
19
|
+
version: '0.34.0'
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function createServer() {
|
|
@@ -46,9 +46,9 @@ export function createServer() {
|
|
|
46
46
|
tools: toolDefinitions
|
|
47
47
|
}))
|
|
48
48
|
|
|
49
|
-
server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema
|
|
49
|
+
server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema>, extra) => {
|
|
50
50
|
const { name, arguments: args } = request.params
|
|
51
|
-
return handleToolCall(name, args as Record<string, unknown
|
|
51
|
+
return handleToolCall(name, args as Record<string, unknown>, extra.signal)
|
|
52
52
|
})
|
|
53
53
|
|
|
54
54
|
return server
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { debuggerSessions } from './debugger/sessions.js'
|
|
3
|
+
import { systemTraces } from './observe/system-trace.js'
|
|
2
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
5
|
import { createServer } from './server-core.js'
|
|
4
6
|
import { getSystemStatus } from './system/index.js'
|
|
@@ -13,6 +15,28 @@ if (process.env.MOBILE_DEBUG_MCP_STARTUP_HEALTHCHECK === '1') {
|
|
|
13
15
|
|
|
14
16
|
const transport = new StdioServerTransport()
|
|
15
17
|
|
|
18
|
+
let shuttingDown = false
|
|
19
|
+
async function shutdown() {
|
|
20
|
+
if (shuttingDown) return
|
|
21
|
+
shuttingDown = true
|
|
22
|
+
const deadline = setTimeout(() => { console.error('System trace shutdown cleanup timed out; owned device captures remain duration-bounded.'); process.exit(1) }, 10000)
|
|
23
|
+
let exitCode = 0
|
|
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)) }
|
|
27
|
+
if (remaining.length) {
|
|
28
|
+
exitCode = 1
|
|
29
|
+
console.error('System trace shutdown cleanup incomplete:', remaining.map(s => ({ trace_id: s.trace_id, device: s.device, cleanup: s.cleanup })))
|
|
30
|
+
}
|
|
31
|
+
await server.close()
|
|
32
|
+
} catch { exitCode = 1; console.error('Server shutdown cleanup failed.') }
|
|
33
|
+
finally { clearTimeout(deadline); process.exit(exitCode) }
|
|
34
|
+
}
|
|
35
|
+
server.onclose = () => { void shutdown() }
|
|
36
|
+
process.stdin.on('end', () => { void shutdown() })
|
|
37
|
+
process.on('SIGINT', () => { void shutdown() })
|
|
38
|
+
process.on('SIGTERM', () => { void shutdown() })
|
|
39
|
+
|
|
16
40
|
async function main() {
|
|
17
41
|
await (server as any).connect(transport)
|
|
18
42
|
}
|
package/src/system/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ interface ToolEntry {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
interface CapabilityEntry {
|
|
36
|
+
probeRequired?: string
|
|
36
37
|
availability: boolean
|
|
37
38
|
selectedProvider: string | null
|
|
38
39
|
blockingFailure: DiagnosticFailure | null
|
|
@@ -41,7 +42,7 @@ interface CapabilityEntry {
|
|
|
41
42
|
interface PlatformEntry {
|
|
42
43
|
status: PlatformStatus
|
|
43
44
|
tools: Record<string, ToolEntry>
|
|
44
|
-
providers: Record<string, { available: boolean; selected: boolean; tool: string | null }>
|
|
45
|
+
providers: Record<string, { available: boolean; selected: boolean; tool: string | null; probeRequired?: string }>
|
|
45
46
|
capabilities: Record<string, CapabilityEntry>
|
|
46
47
|
devices: Array<Record<string, unknown>>
|
|
47
48
|
failures: DiagnosticFailure[]
|
|
@@ -289,7 +290,7 @@ function buildAndroidPlatform(android: Awaited<ReturnType<typeof checkAndroid>>,
|
|
|
289
290
|
},
|
|
290
291
|
providers: {
|
|
291
292
|
adb: { available: adbAvailable, selected: true, tool: 'adb' },
|
|
292
|
-
jdwp: { available:
|
|
293
|
+
jdwp: { available: false, selected: false, tool: 'adb', probeRequired: 'get_debugger_capabilities' }
|
|
293
294
|
},
|
|
294
295
|
capabilities: {
|
|
295
296
|
device_discovery: {
|
|
@@ -323,9 +324,10 @@ function buildAndroidPlatform(android: Awaited<ReturnType<typeof checkAndroid>>,
|
|
|
323
324
|
blockingFailure: logAvailable ? null : adbFailure ?? envFailure
|
|
324
325
|
},
|
|
325
326
|
debug: {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
probeRequired: 'get_debugger_capabilities',
|
|
328
|
+
availability: false,
|
|
329
|
+
selectedProvider: null,
|
|
330
|
+
blockingFailure: deviceFailure ?? adbFailure ?? envFailure
|
|
329
331
|
}
|
|
330
332
|
},
|
|
331
333
|
devices: deviceEntries,
|
package/src/utils/exec.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
1
2
|
import { remainingBudget } from './operation-budget.js'
|
|
2
3
|
import { spawn } from 'child_process'
|
|
3
4
|
|
|
@@ -7,6 +8,7 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
|
|
|
7
8
|
const { env, cwd, shell } = opts
|
|
8
9
|
const timeout = remainingBudget(opts.timeout ?? 0)
|
|
9
10
|
return new Promise((resolve, reject) => {
|
|
11
|
+
checkDebuggerGuard()
|
|
10
12
|
const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
|
|
11
13
|
let stdout = ''
|
|
12
14
|
let stderr = ''
|
package/src/utils/java.ts
CHANGED
|
@@ -21,17 +21,18 @@ function javaVersionOf(javaBin: string): string | undefined {
|
|
|
21
21
|
} catch (e: unknown) { console.debug('[javaVersionOf] java -version failed: ' + String(e)); return undefined }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export async function detectJavaHome(): Promise<string | undefined> {
|
|
24
|
+
export async function detectJavaHome(options: { quiet?: boolean } = {}): Promise<string | undefined> {
|
|
25
|
+
const debug = (...args: unknown[]) => { if (!options.quiet) console.debug(...args) }
|
|
25
26
|
try {
|
|
26
27
|
// 1) Honor explicit ANDROID_STUDIO_JDK env (highest priority)
|
|
27
28
|
const envStudio = process.env.ANDROID_STUDIO_JDK || process.env.ANDROID_STUDIO_JBR
|
|
28
29
|
if (envStudio && existsSync(path.join(envStudio, 'bin', 'java'))) {
|
|
29
30
|
const v = javaVersionOf(path.join(envStudio, 'bin', 'java'))
|
|
30
31
|
if (isJavaVersionAcceptable(v)) {
|
|
31
|
-
|
|
32
|
+
debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio)
|
|
32
33
|
return envStudio
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions')
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
// 2) Android Studio JBR candidates (prefer these over JAVA_HOME)
|
|
@@ -50,7 +51,7 @@ export async function detectJavaHome(): Promise<string | undefined> {
|
|
|
50
51
|
if (existsSync(javaBin)) {
|
|
51
52
|
const v = javaVersionOf(javaBin)
|
|
52
53
|
if (isJavaVersionAcceptable(v)) {
|
|
53
|
-
|
|
54
|
+
debug('[java.detect] Found Android Studio JBR at:', p)
|
|
54
55
|
return p
|
|
55
56
|
}
|
|
56
57
|
}
|
|
@@ -62,12 +63,12 @@ export async function detectJavaHome(): Promise<string | undefined> {
|
|
|
62
63
|
const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java')
|
|
63
64
|
const v = javaVersionOf(javaBin)
|
|
64
65
|
if (isJavaVersionAcceptable(v)) {
|
|
65
|
-
|
|
66
|
+
debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME)
|
|
66
67
|
return process.env.JAVA_HOME
|
|
67
68
|
}
|
|
68
|
-
|
|
69
|
+
debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search')
|
|
69
70
|
} catch {
|
|
70
|
-
|
|
71
|
+
debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK')
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -79,11 +80,11 @@ export async function detectJavaHome(): Promise<string | undefined> {
|
|
|
79
80
|
try {
|
|
80
81
|
const out17 = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
|
|
81
82
|
if (out17) return out17
|
|
82
|
-
} catch (e: unknown) {
|
|
83
|
+
} catch (e: unknown) { debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e)) }
|
|
83
84
|
try {
|
|
84
85
|
const out21 = execSync('/usr/libexec/java_home -v 21', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
|
|
85
86
|
if (out21) return out21
|
|
86
|
-
} catch (e: unknown) {
|
|
87
|
+
} catch (e: unknown) { debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e)) }
|
|
87
88
|
|
|
88
89
|
// 6) macOS common JDK locations
|
|
89
90
|
try {
|
|
@@ -94,7 +95,7 @@ export async function detectJavaHome(): Promise<string | undefined> {
|
|
|
94
95
|
return candidate
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
|
-
} catch (e: unknown) {
|
|
98
|
+
} catch (e: unknown) { debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e)) }
|
|
98
99
|
|
|
99
100
|
// 7) Linux locations
|
|
100
101
|
const linuxCandidates = [
|
|
@@ -106,10 +107,10 @@ export async function detectJavaHome(): Promise<string | undefined> {
|
|
|
106
107
|
'/usr/lib/jvm/java-21-openjdk-amd64'
|
|
107
108
|
]
|
|
108
109
|
for (const p of linuxCandidates) {
|
|
109
|
-
try { if (existsSync(p)) return p } catch (e: unknown) {
|
|
110
|
+
try { if (existsSync(p)) return p } catch (e: unknown) { debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`) }
|
|
110
111
|
}
|
|
111
112
|
} catch (e: unknown) {
|
|
112
|
-
|
|
113
|
+
debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e))
|
|
113
114
|
}
|
|
114
115
|
return undefined
|
|
115
116
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
1
2
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
3
|
|
|
3
4
|
export interface OperationBudget { deadline: number; dispatchStarted: boolean; parent?: OperationBudget }
|
|
@@ -36,6 +37,7 @@ export async function runWithSeparateBudget<T>(timeoutMs: number, operation: ()
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
export function markDispatchStarted() {
|
|
40
|
+
checkDebuggerGuard(true)
|
|
39
41
|
remainingBudget()
|
|
40
42
|
let budget = budgets.getStore()
|
|
41
43
|
while (budget) { budget.dispatchStarted = true; budget = budget.parent }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
1
2
|
import { bindEvidenceDevice } from '../observe/evidence.js'
|
|
2
3
|
import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js'
|
|
3
4
|
import { measure } from './timing.js'
|
|
@@ -56,6 +57,7 @@ export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promi
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceInfo> {
|
|
60
|
+
checkDebuggerGuard()
|
|
59
61
|
const device = await resolveDevice(opts)
|
|
60
62
|
bindEvidenceDevice(device)
|
|
61
63
|
return device
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
3
|
+
import { readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { buildDebuggerFixture, debuggerAppId } from '../../../fixtures/debugger-app/build.js'
|
|
8
|
+
|
|
9
|
+
// Explicit opt-in: installs/removes only this isolated fixture on the selected device.
|
|
10
|
+
if (!process.env.MCP_DEBUGGER_DEVICE) {
|
|
11
|
+
console.log('UNRUN Android debugger fixture: set MCP_DEBUGGER_DEVICE to an authorized emulator serial')
|
|
12
|
+
} else {
|
|
13
|
+
const deviceId = process.env.MCP_DEBUGGER_DEVICE
|
|
14
|
+
const adb = (...args: string[]) => execFileSync(process.env.ADB_PATH || 'adb', ['-s', deviceId, ...args], { encoding: 'utf8', timeout: 15000 })
|
|
15
|
+
const client = new Client({ name: 'android-debugger-smoke', version: '1.0.0' })
|
|
16
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
17
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
18
|
+
const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 45000 })
|
|
19
|
+
const text = (response.content as any[]).find(c => c.type === 'text')!.text!
|
|
20
|
+
return JSON.parse(text)
|
|
21
|
+
}
|
|
22
|
+
const hash = createHash('sha256')
|
|
23
|
+
const fingerprint = async (directory: string): Promise<void> => {
|
|
24
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
25
|
+
const file = directory + '/' + entry.name
|
|
26
|
+
if (entry.isDirectory()) await fingerprint(file)
|
|
27
|
+
else hash.update(file).update(await readFile(file))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await fingerprint('dist'); await fingerprint('worker')
|
|
31
|
+
const buildHash = hash.digest('hex')
|
|
32
|
+
const fixture = buildDebuggerFixture()
|
|
33
|
+
const source = await readFile(new URL('../../../fixtures/debugger-app/MainActivity.java', import.meta.url), 'utf8')
|
|
34
|
+
const line = (marker: string) => source.split('\n').findIndex(l => l.includes(marker)) + 1
|
|
35
|
+
let debugSessionId = ''
|
|
36
|
+
let cursor = 0
|
|
37
|
+
const eventsUntilStop = async () => {
|
|
38
|
+
for (let i = 0; i < 10; i++) {
|
|
39
|
+
const result = await call('wait_for_debugger_event', { debugSessionId, afterSequence: cursor, timeoutMs: 1000 })
|
|
40
|
+
cursor = result.next_cursor
|
|
41
|
+
if (result.events.length) console.log('Debug events', JSON.stringify(result.events))
|
|
42
|
+
const event = result.events.find((e: any) => e.event === 'stopped')
|
|
43
|
+
if (event) return event
|
|
44
|
+
}
|
|
45
|
+
throw new Error('No debugger stop observed')
|
|
46
|
+
}
|
|
47
|
+
const forwardsBefore = adb('forward', '--list').trim()
|
|
48
|
+
try {
|
|
49
|
+
await client.connect(transport)
|
|
50
|
+
const listed = await client.listTools()
|
|
51
|
+
assert.ok(listed.tools.some(tool => tool.name === 'attach_debugger'))
|
|
52
|
+
adb('install', '-r', fixture.artifact)
|
|
53
|
+
adb('shell', 'am', 'start', '-W', '-n', `${debuggerAppId}/.MainActivity`)
|
|
54
|
+
const cap = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: debuggerAppId })
|
|
55
|
+
assert.equal(cap.supported, true, JSON.stringify(cap))
|
|
56
|
+
const timeout = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 100 })
|
|
57
|
+
assert.equal(timeout.error.code, 'DEBUGGER_TIMEOUT')
|
|
58
|
+
const unsupported = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: 'com.missing.debugfixture' })
|
|
59
|
+
assert.equal(unsupported.supported, false)
|
|
60
|
+
assert.ok(unsupported.reasons.includes('PROCESS_NOT_FOUND'))
|
|
61
|
+
const attached = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
|
|
62
|
+
assert.equal(attached.success, true, JSON.stringify(attached)); debugSessionId = attached.debug_session_id
|
|
63
|
+
const bp = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: line('BREAK_CALCULATE') })
|
|
64
|
+
assert.equal(bp.result.status, 'verified', JSON.stringify(bp))
|
|
65
|
+
const pending = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.LateWork`, line: line('BREAK_LATE') })
|
|
66
|
+
assert.equal(pending.result.status, 'pending', JSON.stringify(pending))
|
|
67
|
+
const unavailable = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: 9999 })
|
|
68
|
+
assert.equal(unavailable.result.status, 'rejected')
|
|
69
|
+
// Fixture button placement is obtained from real UI semantics, not fixed coordinates.
|
|
70
|
+
const tree = await call('get_ui_tree', { platform: 'android', deviceId })
|
|
71
|
+
const button = tree.elements.find((e: any) => /^calculate$/i.test(e.text ?? ''))
|
|
72
|
+
assert.ok(button, 'Calculate button visible in real UI tree: ' + JSON.stringify(tree))
|
|
73
|
+
console.log('Fixture target', JSON.stringify({ bounds: button.bounds, device: tree.device, resolution: tree.resolution }))
|
|
74
|
+
const [left, top, right, bottom] = button.bounds
|
|
75
|
+
// Shell input is asynchronous so suspension cannot block the test's next debugger call.
|
|
76
|
+
await writeFile(fixture.directory + '/fixture.png', execFileSync(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'exec-out', 'screencap', '-p']))
|
|
77
|
+
const tap = spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((left + right) / 2)), String(Math.floor((top + bottom) / 2))], { stdio: 'ignore' })
|
|
78
|
+
tap.on('error', error => { throw error })
|
|
79
|
+
const stop = await eventsUntilStop()
|
|
80
|
+
assert.equal(stop.stops[0].location.line, line('BREAK_CALCULATE'))
|
|
81
|
+
const stack = await call('get_debugger_stack', { debugSessionId, stopId: stop.stop_id, threadId: stop.thread_id })
|
|
82
|
+
assert.equal(stack.result.frames[0].location.method, 'calculate')
|
|
83
|
+
const frameId = stack.result.frames[0].frame_id
|
|
84
|
+
const locals = await call('get_debugger_locals', { debugSessionId, stopId: stop.stop_id, frameId })
|
|
85
|
+
assert.equal(locals.result.locals.find((v: any) => v.name === 'known')?.value, '42', JSON.stringify(locals))
|
|
86
|
+
const longText = locals.result.locals.find((v: any) => v.name === 'longText')
|
|
87
|
+
assert.equal(longText.value.length, 256); assert.equal(longText.truncated, true)
|
|
88
|
+
const object = locals.result.locals.find((v: any) => v.name === 'object')
|
|
89
|
+
assert.equal(object.value.type, 'java.lang.Object'); assert.equal(typeof object.value.object_id, 'string')
|
|
90
|
+
const readiness = await call('get_app_readiness', { platform: 'android', deviceId })
|
|
91
|
+
assert.equal(readiness.readiness.state, 'debugger_paused')
|
|
92
|
+
for (const name of ['tap', 'wait_for_ui', 'terminate_app', 'run_journey']) {
|
|
93
|
+
const blocked = await call(name, { platform: 'android', deviceId, x: 5, y: 5, appId: debuggerAppId })
|
|
94
|
+
assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED', name)
|
|
95
|
+
assert.equal(blocked.dispatch_started, false)
|
|
96
|
+
}
|
|
97
|
+
await call('remove_debugger_breakpoint', { debugSessionId, breakpointId: bp.result.breakpoint_id })
|
|
98
|
+
const step = await call('step_debugger', { debugSessionId, stopId: stop.stop_id, threadId: stop.thread_id, kind: 'into' })
|
|
99
|
+
assert.equal(step.success, true, JSON.stringify(step))
|
|
100
|
+
const stale = await call('get_debugger_locals', { debugSessionId, stopId: stop.stop_id, frameId })
|
|
101
|
+
assert.equal(stale.error.code, 'DEBUGGER_STALE_REFERENCE')
|
|
102
|
+
const into = await eventsUntilStop()
|
|
103
|
+
assert.equal(into.stops[0].location.method, 'increment')
|
|
104
|
+
assert.equal((await call('step_debugger', { debugSessionId, stopId: into.stop_id, threadId: into.thread_id, kind: 'over' })).success, true)
|
|
105
|
+
const over = await eventsUntilStop()
|
|
106
|
+
assert.equal(over.stops[0].location.method, 'increment')
|
|
107
|
+
assert.equal((await call('step_debugger', { debugSessionId, stopId: over.stop_id, threadId: over.thread_id, kind: 'out' })).success, true)
|
|
108
|
+
const out = await eventsUntilStop()
|
|
109
|
+
assert.equal(out.stops[0].location.method, 'calculate')
|
|
110
|
+
assert.equal((await call('resume_debugger', { debugSessionId, stopId: out.stop_id })).success, true)
|
|
111
|
+
let resultVisible = false
|
|
112
|
+
for (let i = 0; i < 10; i++) {
|
|
113
|
+
const observed = await call('get_ui_tree', { platform: 'android', deviceId })
|
|
114
|
+
if (observed.elements.some((e: any) => e.text === 'Result 43')) { resultVisible = true; break }
|
|
115
|
+
}
|
|
116
|
+
assert.ok(resultVisible, 'UI tree independently establishes resumed execution: Result 43')
|
|
117
|
+
const noLocalsSource = await readFile(new URL('../../../fixtures/debugger-app/NoLocals.java', import.meta.url), 'utf8')
|
|
118
|
+
const noLocalsLine = noLocalsSource.split('\n').findIndex(line => line.includes('BREAK_NO_LOCALS')) + 1
|
|
119
|
+
const removedPending = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.NoLocals`, line: noLocalsLine })
|
|
120
|
+
await call('remove_debugger_breakpoint', { debugSessionId, breakpointId: removedPending.result.breakpoint_id })
|
|
121
|
+
await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.NoLocals`, line: noLocalsLine })
|
|
122
|
+
const metadataTree = await call('get_ui_tree', { platform: 'android', deviceId })
|
|
123
|
+
const mb = metadataTree.elements.find((e: any) => /^no locals$/i.test(e.text ?? '')).bounds
|
|
124
|
+
spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((mb[0] + mb[2]) / 2)), String(Math.floor((mb[1] + mb[3]) / 2))], { stdio: 'ignore' })
|
|
125
|
+
const metadataStop = await eventsUntilStop()
|
|
126
|
+
assert.ok(metadataStop.stops.every((stop: any) => stop.breakpoint_id !== removedPending.result.breakpoint_id), 'removed pending breakpoint never rebinds')
|
|
127
|
+
const metadataStack = await call('get_debugger_stack', { debugSessionId, stopId: metadataStop.stop_id, threadId: metadataStop.thread_id })
|
|
128
|
+
const metadataLocals = await call('get_debugger_locals', { debugSessionId, stopId: metadataStop.stop_id, frameId: metadataStack.result.frames[0].frame_id })
|
|
129
|
+
assert.equal(metadataLocals.result.availability, 'unknown')
|
|
130
|
+
assert.equal(metadataLocals.result.reason, 'EMPTY_OR_UNAVAILABLE_VARIABLE_METADATA')
|
|
131
|
+
await call('resume_debugger', { debugSessionId, stopId: metadataStop.stop_id })
|
|
132
|
+
const lateTree = await call('get_ui_tree', { platform: 'android', deviceId })
|
|
133
|
+
const lateButton = lateTree.elements.find((e: any) => /^late class$/i.test(e.text ?? ''))
|
|
134
|
+
assert.ok(lateButton)
|
|
135
|
+
const bounds = lateButton.bounds
|
|
136
|
+
spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((bounds[0] + bounds[2]) / 2)), String(Math.floor((bounds[1] + bounds[3]) / 2))], { stdio: 'ignore' })
|
|
137
|
+
const lateStop = await eventsUntilStop()
|
|
138
|
+
assert.equal(lateStop.stops[0].location.class_name, `${debuggerAppId}.LateWork`)
|
|
139
|
+
assert.equal(lateStop.stops[0].location.line, line('BREAK_LATE'))
|
|
140
|
+
const aborted = new AbortController()
|
|
141
|
+
const cancelled = client.callTool({ name: 'wait_for_debugger_event', arguments: { debugSessionId, afterSequence: cursor, timeoutMs: 1000 } }, undefined, { signal: aborted.signal, timeout: 5000 })
|
|
142
|
+
const cancelTimer = setTimeout(() => aborted.abort(), 100)
|
|
143
|
+
try { await assert.rejects(cancelled) } finally { clearTimeout(cancelTimer) }
|
|
144
|
+
assert.equal((await call('get_debugger_state', { debugSessionId })).state, 'paused')
|
|
145
|
+
if (process.env.MCP_DEBUGGER_EXTENDED === '1') {
|
|
146
|
+
console.log('Checking real 60-second pause expiry while polling state')
|
|
147
|
+
const deadline = Date.now() + 65000
|
|
148
|
+
let state: any
|
|
149
|
+
do {
|
|
150
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
151
|
+
state = await call('get_debugger_state', { debugSessionId })
|
|
152
|
+
} while (state.state === 'paused' && Date.now() < deadline)
|
|
153
|
+
assert.equal(state.state, 'detached', JSON.stringify(state))
|
|
154
|
+
assert.equal(state.reason, 'pause_limit')
|
|
155
|
+
assert.equal(state.cleanup.status, 'complete')
|
|
156
|
+
console.log('PASS real pause expiry despite active client polling')
|
|
157
|
+
}
|
|
158
|
+
const detach = await call('detach_debugger', { debugSessionId })
|
|
159
|
+
assert.equal(detach.cleanup.status, 'complete', JSON.stringify(detach))
|
|
160
|
+
assert.equal((await call('detach_debugger', { debugSessionId })).cleanup.status, 'complete')
|
|
161
|
+
assert.equal(adb('forward', '--list').trim(), forwardsBefore, 'unrelated forwards preserved')
|
|
162
|
+
const reattached = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
|
|
163
|
+
assert.equal(reattached.success, true, JSON.stringify(reattached))
|
|
164
|
+
debugSessionId = reattached.debug_session_id
|
|
165
|
+
adb('shell', 'am', 'force-stop', debuggerAppId)
|
|
166
|
+
let disconnected: any
|
|
167
|
+
for (let i = 0; i < 20; i++) {
|
|
168
|
+
disconnected = await call('get_debugger_state', { debugSessionId })
|
|
169
|
+
if (disconnected.cleanup.status === 'complete') break
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
171
|
+
}
|
|
172
|
+
assert.equal(disconnected.state, 'disconnected', JSON.stringify(disconnected))
|
|
173
|
+
assert.equal(disconnected.cleanup.status, 'complete')
|
|
174
|
+
assert.equal(adb('forward', '--list').trim(), forwardsBefore)
|
|
175
|
+
// Disconnect the MCP client with an actively paused session; shutdown owns resume/dispose.
|
|
176
|
+
adb('shell', 'am', 'start', '-W', '-n', `${debuggerAppId}/.MainActivity`)
|
|
177
|
+
const shutdownSession = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
|
|
178
|
+
assert.equal(shutdownSession.success, true, JSON.stringify(shutdownSession))
|
|
179
|
+
debugSessionId = shutdownSession.debug_session_id; cursor = 0
|
|
180
|
+
await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: line('BREAK_CALCULATE') })
|
|
181
|
+
const shutdownTree = await call('get_ui_tree', { platform: 'android', deviceId })
|
|
182
|
+
const shutdownButton = shutdownTree.elements.find((e: any) => /^calculate$/i.test(e.text ?? ''))
|
|
183
|
+
const sb = shutdownButton.bounds
|
|
184
|
+
spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((sb[0] + sb[2]) / 2)), String(Math.floor((sb[1] + sb[3]) / 2))], { stdio: 'ignore' })
|
|
185
|
+
await eventsUntilStop()
|
|
186
|
+
await client.close(); debugSessionId = ''
|
|
187
|
+
assert.equal(adb('forward', '--list').trim(), forwardsBefore, 'MCP disconnect cleans active owned forward')
|
|
188
|
+
const verifier = new Client({ name: 'debugger-shutdown-verifier', version: '1.0.0' })
|
|
189
|
+
try {
|
|
190
|
+
await verifier.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' }))
|
|
191
|
+
const result = await verifier.callTool({ name: 'get_ui_tree', arguments: { platform: 'android', deviceId } })
|
|
192
|
+
const treeAfterShutdown = JSON.parse((result.content as any[]).find(c => c.type === 'text').text)
|
|
193
|
+
assert.ok(treeAfterShutdown.elements.some((e: any) => e.text === 'Result 43'), 'fresh UI proves paused fixture resumed after server shutdown')
|
|
194
|
+
} finally { await verifier.close() }
|
|
195
|
+
console.log(JSON.stringify({ status: 'PASS', build_sha256: buildHash, device: deviceId, api: adb('shell', 'getprop', 'ro.build.version.sdk').trim(), runtime: process.version, transport: 'MCP stdio', extended: process.env.MCP_DEBUGGER_EXTENDED === '1' }))
|
|
196
|
+
console.log('PASS Android debugger: explicit attach, source stop, stack/known locals, pause guards, into/over/out, stale frame, resume UI, detach/forward ownership')
|
|
197
|
+
} finally {
|
|
198
|
+
if (debugSessionId) await call('detach_debugger', { debugSessionId }).catch(() => {})
|
|
199
|
+
await client.close()
|
|
200
|
+
adb('uninstall', debuggerAppId)
|
|
201
|
+
await rm(fixture.directory, { recursive: true, force: true })
|
|
202
|
+
}
|
|
203
|
+
}
|