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.
Files changed (87) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/README.md +7 -0
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/README.md +4 -0
  8. package/agent-plugin/mcp.json +1 -1
  9. package/agent-plugin/plugin.json +1 -1
  10. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
  11. package/dist/debugger/android.js +78 -0
  12. package/dist/debugger/guard.js +30 -0
  13. package/dist/debugger/sessions.js +262 -0
  14. package/dist/debugger/worker.js +165 -0
  15. package/dist/interact/index.js +16 -0
  16. package/dist/observe/evidence.js +9 -2
  17. package/dist/observe/readiness-service.js +9 -0
  18. package/dist/observe/readiness.js +15 -8
  19. package/dist/observe/system-trace-android.js +135 -0
  20. package/dist/observe/system-trace.js +285 -0
  21. package/dist/server/debugger-handlers.js +38 -0
  22. package/dist/server/debugger-schema.js +42 -0
  23. package/dist/server/journey-schema.js +1 -0
  24. package/dist/server/system-trace-schema.js +17 -0
  25. package/dist/server/tool-definitions.js +6 -1
  26. package/dist/server/tool-handlers.js +203 -96
  27. package/dist/server-core.js +3 -3
  28. package/dist/server.js +34 -0
  29. package/dist/system/index.js +5 -4
  30. package/dist/utils/exec.js +2 -0
  31. package/dist/utils/java.js +14 -12
  32. package/dist/utils/operation-budget.js +2 -0
  33. package/dist/utils/resolve-device.js +2 -0
  34. package/docs/CHANGELOG.md +4 -0
  35. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  36. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  37. package/docs/specs/023-android-system-traces/intent.md +40 -0
  38. package/docs/specs/023-android-system-traces/plan.md +117 -0
  39. package/docs/specs/023-android-system-traces/spec.md +120 -0
  40. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  41. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  42. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  43. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  44. package/docs/tools/TOOLS.md +3 -1
  45. package/docs/tools/debugger.md +104 -0
  46. package/docs/tools/evidence.md +5 -2
  47. package/docs/tools/observe.md +12 -5
  48. package/docs/tools/system-traces.md +126 -0
  49. package/package.json +1 -1
  50. package/src/debugger/android.ts +65 -0
  51. package/src/debugger/guard.ts +26 -0
  52. package/src/debugger/sessions.ts +168 -0
  53. package/src/debugger/worker.ts +122 -0
  54. package/src/interact/index.ts +16 -15
  55. package/src/observe/evidence.ts +9 -2
  56. package/src/observe/readiness-service.ts +5 -0
  57. package/src/observe/readiness.ts +12 -6
  58. package/src/observe/system-trace-android.ts +133 -0
  59. package/src/observe/system-trace.ts +218 -0
  60. package/src/server/debugger-handlers.ts +29 -0
  61. package/src/server/debugger-schema.ts +37 -0
  62. package/src/server/journey-schema.ts +1 -0
  63. package/src/server/system-trace-schema.ts +17 -0
  64. package/src/server/tool-definitions.ts +6 -1
  65. package/src/server/tool-handlers.ts +92 -7
  66. package/src/server-core.ts +3 -3
  67. package/src/server.ts +24 -0
  68. package/src/system/index.ts +7 -5
  69. package/src/utils/exec.ts +2 -0
  70. package/src/utils/java.ts +13 -12
  71. package/src/utils/operation-budget.ts +2 -0
  72. package/src/utils/resolve-device.ts +2 -0
  73. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  74. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  75. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  76. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  77. package/test/fixtures/debugger-app/README.md +13 -0
  78. package/test/fixtures/debugger-app/build.ts +29 -0
  79. package/test/helpers/perfetto.ts +59 -0
  80. package/test/unit/debugger/guards.test.ts +46 -0
  81. package/test/unit/debugger/native.test.ts +33 -0
  82. package/test/unit/debugger/sessions.test.ts +83 -0
  83. package/test/unit/observe/readiness.test.ts +3 -1
  84. package/test/unit/observe/system-trace-android.test.ts +52 -0
  85. package/test/unit/observe/system-trace.test.ts +166 -0
  86. package/test/unit/server/system-trace.test.ts +64 -0
  87. package/worker/DebuggerWorker.java +160 -0
@@ -0,0 +1,104 @@
1
+ import { execCmd } from '../../../../src/utils/exec.js'
2
+ import { getAdbCmd } from '../../../../src/utils/android/utils.js'
3
+ import { createHash } from 'node:crypto'
4
+ import assert from 'node:assert/strict'
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
6
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
7
+ import { promises as fs } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { inspectPerfetto } from '../../../helpers/perfetto.js'
10
+
11
+ async function run() {
12
+ if (process.env.SKIP_DEVICE_TESTS === '1') { console.log('System trace smoke: SKIPPED (SKIP_DEVICE_TESTS=1)'); return }
13
+ const client = new Client({ name: 'system-trace-smoke', version: '1.0.0' })
14
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
15
+ const parse = (r: any) => JSON.parse(r.content[0].text)
16
+ const call = async (name: string, args: Record<string, unknown>) => parse(await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 }))
17
+ const owned: string[] = [], artifacts: string[] = []
18
+ try {
19
+ await client.connect(transport)
20
+ const devices = await call('list_devices', {})
21
+ const device = devices.devices.find((d: any) => d.platform === 'android' && d.simulator)
22
+ assert.ok(device, 'A running Android emulator is required')
23
+ const target = { platform: 'android', deviceId: device.id }
24
+ const cap = await call('get_system_trace_capabilities', target)
25
+ assert.equal(cap.supported, true, JSON.stringify(cap))
26
+ const fixtureLaunch = await call('start_app', { ...target, appId: 'com.android.settings', verificationMode: 'none', responseMode: 'compact' })
27
+ assert.equal(fixtureLaunch.success, true)
28
+ const baseline = await call('get_ui_tree', { ...target, responseMode: 'debug' })
29
+ const settingsNode = baseline.elements?.find((e: any) => e.visible && e.resourceId?.startsWith('com.android.settings:id/'))
30
+ assert.ok(settingsNode, 'Fresh baseline must contain a visible Settings-owned UI resource')
31
+ const settingsSelector = { resource_id: settingsNode.resourceId }
32
+ const start = await call('start_system_trace', { ...target, durationMs: 30000 })
33
+ owned.push(start.trace_id)
34
+ assert.equal(start.state, 'recording', JSON.stringify(start))
35
+ const journey = await call('run_journey', { ...target, systemTraceId: start.trace_id,
36
+ steps: [
37
+ { id: 'settings', type: 'start_app', appId: 'com.android.settings', verificationMode: 'none' },
38
+ { id: 'settings-visible', type: 'assert', timeoutMs: 10000, assertion: { kind: 'element_visible', selector: settingsSelector } }
39
+ ] })
40
+ assert.equal(journey.success, true, JSON.stringify(journey))
41
+ assert.equal(journey.steps[1].status, 'passed', 'The baseline Settings-owned UI element must remain semantically visible')
42
+ assert.equal(journey.evidence.trace_context.trace_id, start.trace_id)
43
+ assert.equal(journey.steps[0].result.evidence.trace_context.trace_id, start.trace_id)
44
+ assert.equal(journey.system_trace.coverage, 'covered')
45
+ const stop = await call('stop_system_trace', { traceId: start.trace_id })
46
+ if (stop.artifact) artifacts.push(stop.artifact.path)
47
+ assert.equal(stop.state, 'completed', JSON.stringify(stop)); assert.equal(stop.cleanup.status, 'complete')
48
+ assert.deepEqual(stop.correlation_ids, [journey.evidence.correlation_id])
49
+ const content = inspectPerfetto(await fs.readFile(stop.artifact.path), 30000)
50
+ assert.equal((await call('stop_system_trace', { traceId: start.trace_id })).artifact.path, stop.artifact.path)
51
+ const rejected = await call('run_journey', { ...target, systemTraceId: start.trace_id,
52
+ steps: [{ id: 'must-not-launch', type: 'start_app', appId: 'com.invalid.trace.smoke' }] })
53
+ assert.equal(rejected.error.code, 'TRACE_NOT_FOUND')
54
+ const cancel = await call('start_system_trace', { ...target, durationMs: 30000 })
55
+ owned.push(cancel.trace_id)
56
+ const cancelled = await call('cancel_system_trace', { traceId: cancel.trace_id })
57
+ assert.equal(cancelled.state, 'cancelled', JSON.stringify(cancelled))
58
+ assert.equal((await call('get_system_trace_status', { traceId: cancel.trace_id })).artifact, null)
59
+ const short = await call('start_system_trace', { ...target, durationMs: 1001 })
60
+ owned.push(short.trace_id)
61
+ assert.equal(short.state, 'recording', JSON.stringify(short))
62
+ let ended = short
63
+ for (let i = 0; i < 30 && ended.state === 'recording'; i++) {
64
+ await new Promise(resolve => setTimeout(resolve, 200))
65
+ ended = await call('get_system_trace_status', { traceId: short.trace_id })
66
+ }
67
+ if (ended.artifact) artifacts.push(ended.artifact.path)
68
+ assert.equal(ended.state, 'completed', JSON.stringify(ended)); assert.equal(ended.completion_reason, 'duration_limit')
69
+ const durationContent = inspectPerfetto(await fs.readFile(ended.artifact.path), 1001)
70
+ const shutdownClient = new Client({ name: 'system-trace-shutdown-smoke', version: '1.0.0' })
71
+ const shutdownTransport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
72
+ let shutdownTrace: any
73
+ let shutdownDiagnostics = ''
74
+ try {
75
+ await shutdownClient.connect(shutdownTransport)
76
+ shutdownTransport.stderr?.on('data', data => { shutdownDiagnostics += String(data) })
77
+ shutdownTrace = parse(await shutdownClient.callTool({ name: 'start_system_trace', arguments: { ...target, durationMs: 30000 } }, undefined, { timeout: 120000 }))
78
+ assert.equal(shutdownTrace.state, 'recording')
79
+ } finally { await shutdownClient.close() }
80
+ const remote = shutdownTrace.cleanup.remaining_resources.find((r: string) => r.startsWith('/data/misc/perfetto-traces/mcp-'))
81
+ assert.ok(remote)
82
+ const absent = await execCmd(getAdbCmd(), ['-s', device.id, 'shell', 'test', '!', '-e', remote], { timeout: 10000 })
83
+ assert.equal(absent.exitCode, 0, `Shutdown must stop and remove its owned device capture: ${shutdownTrace.trace_id}; ${shutdownDiagnostics}; ${absent.stderr}`)
84
+ const buildHash = createHash('sha256')
85
+ for (const file of ['dist/server.js', 'dist/observe/system-trace.js', 'dist/observe/system-trace-android.js', 'dist/server/tool-handlers.js', 'dist/observe/evidence.js']) buildHash.update(await fs.readFile(file))
86
+ const report = { status: 'PASS', build_sha256: buildHash.digest('hex'), runtime: process.version, server: client.getServerVersion(), device: device.id, osVersion: device.osVersion,
87
+ starting_state: 'System Settings fixture', journey: 'start Settings, assert baseline Settings-owned resource visible in UI tree; explicit trace association',
88
+ trace_id: start.trace_id, correlation_id: journey.evidence.correlation_id, phases: {
89
+ capture_started_at_ms: stop.capture_started_at_ms, capture_completed_at_ms: stop.capture_completed_at_ms },
90
+ artifact_bytes: stop.artifact.size_bytes, content, durationContent, cancel: 'PASS', expired_journey: 'PASS', active_shutdown: 'PASS',
91
+ cleanup: 'Device capture files removed; test host artifacts removed in finally. No settings or app data changed. Settings remains foreground.' }
92
+ console.log(JSON.stringify(report))
93
+ if (process.env.SYSTEM_TRACE_EVIDENCE_PATH) await fs.writeFile(process.env.SYSTEM_TRACE_EVIDENCE_PATH, JSON.stringify(report, null, 2) + '\n')
94
+ } finally {
95
+ const cleanupErrors: string[] = []
96
+ for (const traceId of owned) {
97
+ try { const r = await call('cancel_system_trace', { traceId }); if (r.artifact && !artifacts.includes(r.artifact.path)) artifacts.push(r.artifact.path); if (r.cleanup?.status !== 'complete') cleanupErrors.push(traceId) } catch { cleanupErrors.push(traceId) }
98
+ }
99
+ await client.close()
100
+ for (const file of artifacts) await fs.rm(path.dirname(file), { recursive: true, force: true })
101
+ assert.deepEqual(cleanupErrors, [], 'Owned trace cleanup failed')
102
+ }
103
+ }
104
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,46 @@
1
+ package dev.mobiledebugmcp.debugfixture;
2
+ import android.app.Activity;
3
+ import android.os.Bundle;
4
+ import android.widget.Button;
5
+ import android.widget.LinearLayout;
6
+ import android.widget.TextView;
7
+
8
+ public class MainActivity extends Activity {
9
+ TextView status;
10
+ @Override public void onCreate(Bundle saved) {
11
+ super.onCreate(saved);
12
+ LinearLayout layout = new LinearLayout(this); layout.setOrientation(1); layout.setPadding(0, 160, 0, 0);
13
+ status = new TextView(this); status.setText("Ready"); layout.addView(status);
14
+ Button button = new Button(this); button.setText("Calculate");
15
+ button.setOnClickListener(v -> calculate(7)); layout.addView(button);
16
+ Button late = new Button(this); late.setText("Late class");
17
+ late.setOnClickListener(v -> {
18
+ try { Class.forName("dev.mobiledebugmcp.debugfixture.LateWork").getDeclaredMethod("work").invoke(null); }
19
+ catch (Exception e) { status.setText("Late failed"); }
20
+ }); layout.addView(late);
21
+ Button noLocals = new Button(this); noLocals.setText("No locals");
22
+ noLocals.setOnClickListener(v -> {
23
+ try { Class.forName("dev.mobiledebugmcp.debugfixture.NoLocals").getDeclaredMethod("work").invoke(null); }
24
+ catch (Exception e) { status.setText("Metadata failed"); }
25
+ }); layout.addView(noLocals);
26
+ setContentView(layout);
27
+ }
28
+ void calculate(int seed) {
29
+ int known = seed + 35;
30
+ String longText = new String(new char[300]).replace('\0', 'x');
31
+ Object object = new Object();
32
+ int answer = increment(known); // BREAK_CALCULATE
33
+ status.setText("Result " + answer); // BREAK_RESULT
34
+ }
35
+ int increment(int value) {
36
+ int result = value + 1; // BREAK_INCREMENT
37
+ return result;
38
+ }
39
+ }
40
+
41
+ class LateWork {
42
+ public static void work() {
43
+ int local = 99; // BREAK_LATE
44
+ android.util.Log.i("DebugFixture", "late " + local);
45
+ }
46
+ }
@@ -0,0 +1,7 @@
1
+ package dev.mobiledebugmcp.debugfixture;
2
+ public class NoLocals {
3
+ public static void work() {
4
+ int hidden = 5; // BREAK_NO_LOCALS
5
+ android.util.Log.i("DebugFixture", "metadata " + hidden);
6
+ }
7
+ }
@@ -0,0 +1,13 @@
1
+ # Isolated Android debugger fixture
2
+
3
+ Package `dev.mobiledebugmcp.debugfixture`, built with `android:debuggable=true`
4
+ and Java source/line/local metadata. Calculate exposes `known=42`, an increment
5
+ call for into/over/out stepping, and a visible `Result 43` after resume. Late class
6
+ loads a second class reflectively to check pending source breakpoints. NoLocals
7
+ is compiled with source/line tables and no variable tables to verify explicit ART
8
+ metadata uncertainty.
9
+
10
+ Build helper uses SDK platform/build-tools 36, the existing direct javac/D8/aapt2
11
+ pattern, ephemeral signing material and temporary output. It does not modify other
12
+ apps or display settings. The automated journey installs/removes only this fixture
13
+ and checks session-owned ADB forwards. See [debugger tools](../../../docs/tools/debugger.md).
@@ -0,0 +1,29 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ export const debuggerAppId = 'dev.mobiledebugmcp.debugfixture'
7
+ export function buildDebuggerFixture() {
8
+ const source = path.dirname(fileURLToPath(import.meta.url))
9
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-debugger-fixture-'))
10
+ const java = process.env.JAVA_HOME ?? (existsSync('/Applications/Android Studio.app/Contents/jbr/Contents/Home') ? '/Applications/Android Studio.app/Contents/jbr/Contents/Home' : undefined)
11
+ const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: directory, encoding: 'utf8', stdio: 'pipe' })
12
+ const sdk = process.env.ANDROID_HOME ?? path.join(process.env.HOME!, 'Library/Android/sdk')
13
+ const tools = path.join(sdk, 'build-tools/36.0.0')
14
+ const androidJar = path.join(sdk, 'platforms/android-36/android.jar')
15
+ const jdk = (name: string) => java ? path.join(java, 'bin', name) : name
16
+ mkdirSync(path.join(directory, 'classes'))
17
+ writeFileSync(path.join(directory, 'AndroidManifest.xml'), `<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${debuggerAppId}" android:versionCode="1" android:versionName="1.0"><uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36"/><application android:debuggable="true" android:label="Debugger fixture" android:theme="@android:style/Theme.Material.Light.NoActionBar"><activity android:name=".MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity></application></manifest>`)
18
+ run(jdk('javac'), ['-g', '--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(source, 'MainActivity.java')])
19
+ run(jdk('javac'), ['-g:source,lines', '--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(source, 'NoLocals.java')])
20
+ run(jdk('jar'), ['cf', 'classes.jar', '-C', 'classes', '.'])
21
+ run(path.join(tools, 'd8'), ['--lib', androidJar, '--output', directory, 'classes.jar'])
22
+ run(path.join(tools, 'aapt2'), ['link', '-I', androidJar, '--manifest', 'AndroidManifest.xml', '-o', 'unsigned.apk'])
23
+ run('zip', ['-q', 'unsigned.apk', 'classes.dex'])
24
+ run(path.join(tools, 'zipalign'), ['-f', '4', 'unsigned.apk', 'aligned.apk'])
25
+ run(jdk('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=Readiness Fixture', '-keyalg', 'RSA', '-validity', '365'])
26
+ const artifact = path.join(directory, 'Debugger.apk')
27
+ run(path.join(tools, 'apksigner'), ['sign', '--ks', 'fixture.jks', '--ks-pass', 'pass:android', '--out', artifact, 'aligned.apk'])
28
+ return { artifact, directory }
29
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ // Minimal local protobuf reader for format/content evidence, not trace analysis.
4
+ // Field numbers: google/perfetto protos/perfetto/trace/{trace,trace_packet}.proto
5
+ // and trace/ftrace/{ftrace_event_bundle,ftrace_event}.proto. Unknown fields skipped.
6
+ export function protobufFields(bytes: Buffer): Array<{ field: number; wire: number; value: Buffer | bigint }> {
7
+ let offset = 0
8
+ const result: Array<{ field: number; wire: number; value: Buffer | bigint }> = []
9
+ function varint() {
10
+ let value = 0n
11
+ for (let shift = 0n; shift < 70n; shift += 7n) {
12
+ assert.ok(offset < bytes.length, 'Truncated protobuf varint')
13
+ const byte = bytes[offset++]
14
+ value |= BigInt(byte & 127) << shift
15
+ if (!(byte & 128)) return value
16
+ }
17
+ throw new Error('Oversized protobuf varint')
18
+ }
19
+ while (offset < bytes.length) {
20
+ const key = Number(varint()), field = Math.floor(key / 8), wire = key % 8
21
+ assert.ok(field > 0)
22
+ let value: Buffer | bigint
23
+ if (wire === 0) value = varint()
24
+ else {
25
+ const length = wire === 2 ? Number(varint()) : wire === 1 ? 8 : wire === 5 ? 4 : -1
26
+ assert.ok(Number.isSafeInteger(length) && length >= 0 && length <= bytes.length - offset, 'Invalid protobuf length/wire')
27
+ value = bytes.subarray(offset, offset + length); offset += length
28
+ }
29
+ result.push({ field, wire, value })
30
+ }
31
+ return result
32
+ }
33
+ export function inspectPerfetto(bytes: Buffer, duration: number) {
34
+ const root = protobufFields(bytes)
35
+ assert.ok(root.length > 0)
36
+ assert.ok(root.every(f => f.field === 1 && f.wire === 2), 'Trace.packet must be repeated length-delimited messages')
37
+ const packets = root.map(f => protobufFields(f.value as Buffer))
38
+ const config = packets.flat().find(f => f.field === 33 && f.wire === 2)
39
+ assert.ok(config, 'Missing TracePacket.trace_config')
40
+ assert.equal(protobufFields(config.value as Buffer).find(f => f.field === 3)?.value, BigInt(duration))
41
+ const bundles = packets.flat().filter(f => f.field === 1 && f.wire === 2).map(f => protobufFields(f.value as Buffer))
42
+ let events = 0, compactBundles = 0
43
+ for (const bundle of bundles) {
44
+ for (const f of bundle) {
45
+ if (f.field === 2 && f.wire === 2) {
46
+ const event = protobufFields(f.value as Buffer)
47
+ assert.ok(event.some(e => e.field === 1 && e.wire === 0), 'FtraceEvent timestamp missing')
48
+ events++
49
+ }
50
+ if (f.field === 4 && f.wire === 2) {
51
+ assert.ok(protobufFields(f.value as Buffer).some(e => [1, 7].includes(e.field) && e.wire === 2))
52
+ compactBundles++
53
+ }
54
+ }
55
+ }
56
+ assert.ok(events + compactBundles > 0, 'No captured kernel/scheduler events')
57
+ return { packets: packets.length, ftrace_bundles: bundles.length, events, compact_sched_bundles: compactBundles,
58
+ validation: 'local protobuf structure, trace config duration and timestamped ftrace/compact scheduler content; not full Perfetto analysis' }
59
+ }
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { debuggerSessions } from '../../../src/debugger/sessions.js'
3
+ import { withDebuggerGuard, checkDebuggerGuard } from '../../../src/debugger/guard.js'
4
+ import { handleToolCall } from '../../../src/server-core.js'
5
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
6
+ import { ToolsObserve } from '../../../src/observe/index.js'
7
+ import { ToolsInteract } from '../../../src/interact/index.js'
8
+ import { getAppReadiness } from '../../../src/observe/readiness-service.js'
9
+ const originalPause = debuggerSessions.paused
10
+ const originalActive = debuggerSessions.hasActiveSessions
11
+ const originalTree = ToolsObserve.getUITreeHandler
12
+ const originalWait = ToolsInteract.waitForUIHandler
13
+ let paused = true
14
+ const pause: any = { debug_session_id: 'session', target: { app_id: 'test.app', pid: 10 }, state: 'paused', captured_at_ms: 1000 }
15
+ debuggerSessions.paused = device => paused && device === 'fixture' ? pause : null
16
+ debuggerSessions.hasActiveSessions = () => true
17
+ _setDeviceListersForTests({ listAndroidDevices: async () => [{ id: 'fixture', platform: 'android' } as any] })
18
+ const payload = (response: any) => JSON.parse(response.content.find((c: any) => c.type === 'text').text)
19
+ try {
20
+ ToolsObserve.getUITreeHandler = async () => { throw new Error('Paused readiness must not acquire a UI tree') }
21
+ const readiness = await getAppReadiness({ platform: 'android', deviceId: 'fixture' })
22
+ assert.equal(readiness.readiness.state, 'debugger_paused')
23
+ const wait = await getAppReadiness({ platform: 'android', deviceId: 'fixture' }, true)
24
+ assert.equal(wait.failure_code, 'DEBUGGER_PAUSED')
25
+ const blocked = payload(await handleToolCall('tap', { platform: 'android', deviceId: 'fixture', x: 1, y: 1 }))
26
+ assert.equal(blocked.delivery_status, 'not_delivered')
27
+ await withDebuggerGuard('android', 'other', async () => checkDebuggerGuard(true))
28
+ await assert.rejects(withDebuggerGuard('android', 'fixture', async () => checkDebuggerGuard()), /DEBUGGER_PAUSED/)
29
+ paused = false
30
+ let calls = 0
31
+ ToolsInteract.waitForUIHandler = async () => { calls++; if (calls === 2) paused = true; return { status: 'success' } as any }
32
+ const journey = payload(await handleToolCall('run_journey', { platform: 'android', deviceId: 'fixture', responseMode: 'compact', steps: [
33
+ { id: 'first', type: 'wait', selector: { text: 'First' } },
34
+ { id: 'second', type: 'wait', selector: { text: 'Second' } },
35
+ { id: 'third', type: 'wait', selector: { text: 'Third' } }
36
+ ] }))
37
+ assert.equal(journey.steps[0].status, 'passed', 'completed steps survive a later pause')
38
+ assert.equal(journey.steps[1].success, false)
39
+ assert.equal(journey.steps[1].result.failure_code, 'DEBUGGER_PAUSED')
40
+ assert.equal(calls, 2, 'no action queued or retried after pause')
41
+ console.log('PASS debugger readiness without UI acquisition, device scoping, dispatch guard, mid-journey pause preservation')
42
+ } finally {
43
+ debuggerSessions.paused = originalPause; debuggerSessions.hasActiveSessions = originalActive
44
+ ToolsObserve.getUITreeHandler = originalTree; ToolsInteract.waitForUIHandler = originalWait
45
+ _resetDeviceListersForTests()
46
+ }
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AndroidDebugNative } from '../../../src/debugger/android.js'
3
+ import { DebuggerError, debuggerRemaining, withDebuggerBudget } from '../../../src/debugger/worker.js'
4
+ class Native extends AndroidDebugNative {
5
+ calls: string[][] = []
6
+ forwards = 'device tcp:1234 jdwp:10\nother tcp:3456 jdwp:20'
7
+ async adb(_device: string, args: string[]) {
8
+ this.calls.push(args)
9
+ if (args[0] === 'forward' && args[1] === '--list') return this.forwards
10
+ if (args.at(-1)?.endsWith('/stat')) return '10 (complex process name) S ' + Array(18).fill('0').join(' ') + ' 999 0'
11
+ if (args.at(-1)?.endsWith('/status')) return 'Name:\ttest\nUid:\t42\t42\t42\t42'
12
+ if (args.at(-1)?.endsWith('/cmdline')) return 'test.app\0'
13
+ return ''
14
+ }
15
+ }
16
+ const native = new Native()
17
+ await native.removeForward('device', 1234, 10)
18
+ assert.deepEqual(native.calls.at(-1), ['forward', '--remove', 'tcp:1234'])
19
+ await assert.rejects(native.removeForward('device', 3456, 10), /DEBUGGER_CLEANUP_FAILED/)
20
+ const before = native.calls.length
21
+ await native.removeForward('device', 9999, 10)
22
+ assert.equal(native.calls.length, before + 1, 'absent owned forward requires no removal')
23
+ const target = { app_id: 'test.app', pid: 10, process_name: 'test.app', start_identity: '', uid: 42 }
24
+ assert.equal((await native.identity('device', target)).start_identity, '999')
25
+ await assert.rejects(native.identity('device', { ...target, start_identity: 'old' }), /PROCESS_CHANGED/)
26
+ await assert.rejects(native.identity('device', { ...target, uid: 44 }), /PROCESS_CHANGED/)
27
+ await assert.rejects(native.candidates('device', 'test.app;evil'), /INVALID_ARGUMENT/)
28
+ const aborted = new AbortController(); aborted.abort()
29
+ await assert.rejects(withDebuggerBudget(100, aborted.signal, async () => debuggerRemaining(10)), /DEBUGGER_CANCELLED/)
30
+ await assert.rejects(withDebuggerBudget(-1, undefined, async () => debuggerRemaining(10)), /DEBUGGER_TIMEOUT/)
31
+ assert.equal(await withDebuggerBudget(100, undefined, async () => debuggerRemaining(10)), 10)
32
+ assert.equal(new DebuggerError('DEBUGGER_TIMEOUT').code, 'DEBUGGER_TIMEOUT')
33
+ console.log('PASS debugger native identity, PID reuse, forward ownership, deadlines and cancellation')
@@ -0,0 +1,83 @@
1
+ import assert from 'node:assert/strict'
2
+ import { DebuggerSessions, debuggerExpiry } from '../../../src/debugger/sessions.js'
3
+ import { DebuggerError, type WorkerEvent } from '../../../src/debugger/worker.js'
4
+ import type { DebugNative, DebugTarget } from '../../../src/debugger/android.js'
5
+ import { debuggerContracts, debuggerTools } from '../../../src/server/debugger-schema.js'
6
+ assert.equal(debuggerExpiry(0, 0, 0, 59999), null)
7
+ assert.equal(debuggerExpiry(0, 60000, 0, 60000), 'pause_limit', 'polling cannot extend pause expiry')
8
+ assert.equal(debuggerExpiry(0, 0, undefined, 300000), 'idle_limit')
9
+ assert.equal(debuggerExpiry(0, 1800000, undefined, 1800000), 'session_limit', 'activity cannot extend lifetime')
10
+ const target: DebugTarget = { app_id: 'test.app', pid: 123, uid: 456, process_name: 'test.app', start_identity: '100' }
11
+ let candidates = [target]
12
+ let removed = 0
13
+ let emit: (event: WorkerEvent) => void = () => {}
14
+ let close = 0
15
+ let failCleanup = false
16
+ let failCommand = ''
17
+ const native: DebugNative = {
18
+ candidates: async () => candidates, identity: async () => target, forward: async () => 12345,
19
+ removeForward: async () => { if (failCleanup) throw new Error('offline'); removed++ }
20
+ }
21
+ const sessions = new DebuggerSessions(native, async event => {
22
+ emit = event
23
+ return { call: async (command: string) => { if (command === failCommand) throw new DebuggerError('DEBUGGER_TIMEOUT'); return { state: 'running', breakpoints: [] } }, close: async () => { close++ } }
24
+ }, async () => ({ java: 'java', directory: '/unused' }))
25
+ try {
26
+ candidates = [target, { ...target, pid: 124 }]
27
+ const ambiguous = await sessions.attach('emulator', 'test.app')
28
+ assert.equal(ambiguous.error.code, 'PROCESS_AMBIGUOUS')
29
+ assert.equal(removed, 0)
30
+ candidates = [target]
31
+ const first = await sessions.attach('emulator', 'test.app')
32
+ const id = first.debug_session_id
33
+ assert.equal(first.state, 'running')
34
+ await assert.rejects(sessions.attach('emulator', 'test.app'), /DEBUGGER_BUSY/)
35
+ emit({ event: 'stopped', stop_id: 'stop-1', thread_id: 't1', stops: [] })
36
+ assert.equal(sessions.paused('emulator')?.stop.stop_id, 'stop-1')
37
+ assert.equal(sessions.paused('other'), null)
38
+ assert.throws(() => sessions.assertRunning('emulator'), /DEBUGGER_PAUSED/)
39
+ const pendingWait = sessions.command(id, 'events', { afterSequence: 1 }, 500)
40
+ await sessions.command(id, 'resume', { stopId: 'stop-1' })
41
+ assert.equal(sessions.paused('emulator'), null)
42
+ await assert.rejects(sessions.command(id, 'locals', { stopId: 'stop-1', frameId: 'old' }), /DEBUGGER_STALE_REFERENCE/)
43
+ emit({ event: 'stopped', stop_id: 'stop-2', thread_id: 't2' })
44
+ assert.equal((await pendingWait).events[0].stop_id, 'stop-2', 'event wait cannot block resume')
45
+ for (let i = 0; i < 260; i++) emit({ event: 'breakpoint', i })
46
+ const events = await sessions.command(id, 'events', { afterSequence: 0 }, 100)
47
+ assert.equal(events.overflow, true); assert.equal(events.events.length, 100)
48
+ await assert.rejects(sessions.command(id, 'events', { afterSequence: 9999 }, 100), /INVALID_ARGUMENT/)
49
+ const controller = new AbortController(); controller.abort()
50
+ await assert.rejects(sessions.command(id, 'events', { afterSequence: 262, signal: controller.signal }, 100), /DEBUGGER_CANCELLED/)
51
+ failCommand = 'locals'
52
+ await assert.rejects(sessions.command(id, 'locals', { stopId: 'stop-2' }, 100), /DEBUGGER_TIMEOUT/)
53
+ assert.equal(sessions.paused('emulator')?.state, 'paused', 'inspection timeout preserves known suspension')
54
+ failCommand = 'step'
55
+ await assert.rejects(sessions.command(id, 'step', { stopId: 'stop-2', threadId: 't2' }, 100), /DEBUGGER_TIMEOUT/)
56
+ assert.equal((await sessions.command(id, 'state')).cleanup.status, 'complete')
57
+ assert.equal(close, 1); assert.equal(removed, 1)
58
+ failCommand = ''
59
+ const second = await sessions.attach('emulator', 'test.app')
60
+ failCleanup = true
61
+ const partial = await sessions.command(second.debug_session_id, 'detach')
62
+ assert.equal(partial.success, false); assert.equal(partial.cleanup.status, 'failed')
63
+ await assert.rejects(sessions.attach('emulator', 'test.app'), /DEBUGGER_BUSY/)
64
+ failCleanup = false
65
+ const retry = await sessions.command(second.debug_session_id, 'detach')
66
+ assert.equal(retry.cleanup.status, 'complete'); assert.equal(close, 2)
67
+ assert.equal((await sessions.command(second.debug_session_id, 'detach')).cleanup.status, 'complete')
68
+ const third = await sessions.attach('emulator', 'test.app')
69
+ emit({ event: 'worker_failed' })
70
+ const crashed = await sessions.command(third.debug_session_id, 'state')
71
+ assert.equal(crashed.state, 'failed')
72
+ assert.equal(crashed.cleanup.status, 'complete')
73
+ assert.equal(crashed.error.code, 'DEBUGGER_WORKER_FAILED')
74
+ assert.equal(sessions.paused('emulator'), null)
75
+ for (const tool of debuggerTools) {
76
+ const contract = debuggerContracts[tool.name]
77
+ assert.equal(tool.inputSchema.additionalProperties, false)
78
+ assert.equal(contract.schema.safeParse({ ...Object.fromEntries(tool.inputSchema.required.map(k => [k, 'x'])), unexpected: true }).success, false)
79
+ }
80
+ assert.equal(debuggerContracts.attach_debugger.schema.safeParse({ platform: 'android', appId: 'test.app' }).success, false)
81
+ assert.equal(debuggerContracts.set_debugger_breakpoint.schema.safeParse({ debugSessionId: 's', className: '$(bad)', line: 2 }).success, false)
82
+ console.log('PASS debugger session ownership, stop epochs, event concurrency/overflow, timeout recovery, cleanup retries, strict contracts')
83
+ } finally { await sessions.shutdown() }
@@ -32,8 +32,10 @@ for (const extra of [{ error: 'offline' }, { elements: [] }, { captured_at_ms: 9
32
32
  for (let index = 0; index < 20; index++) tracker.observe(sample(), 10000, 8000 + index)
33
33
  assert.equal(tracker.summary.evidence.length, 8)
34
34
  assert.equal(tracker.summary.capabilities.unresponsive, false)
35
- assert.equal(tracker.summary.capabilities.debugger_paused, false)
35
+ assert.equal(tracker.summary.capabilities.debugger_paused, true)
36
36
  assert.ok(tracker.deadline().limitations.includes('DEADLINE_EXCEEDED'))
37
37
  assert.equal(new ReadinessTracker('android', 'fixture').observe(sample(), 10000, 9000).state, 'unknown', 'no cross-request history')
38
38
  assert.ok(!JSON.stringify(tracker.summary).includes('Ready'), 'evidence contains codes rather than private labels')
39
+ assert.equal(new ReadinessTracker('ios', 'fixture').summary.capabilities.debugger_paused, false)
40
+ assert.ok(tracker.summary.limitations.includes('MANAGED_DEBUGGER_ONLY'))
39
41
  console.log('Readiness classifier: PASS')
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AndroidTraceNative } from '../../../src/observe/system-trace-android.js'
3
+ import type { execCmd } from '../../../src/utils/exec.js'
4
+
5
+ async function run() {
6
+ const calls: Array<{ args: string[]; timeout: number | undefined }> = []
7
+ let failure = '', processes = ''
8
+ const execute: typeof execCmd = async (_cmd, args, options = {}) => {
9
+ calls.push({ args, timeout: options.timeout })
10
+ assert.equal(args[0], '-s'); assert.equal(args[1], 'serial with spaces')
11
+ if (failure && args.join(' ').includes(failure)) return { exitCode: null, stdout: '', stderr: 'ACTION_TIMEOUT' }
12
+ let out = ''
13
+ if (args.includes('--help')) return { exitCode: 1, stdout: '', stderr: 'Usage: perfetto --background-wait --config' }
14
+ if (args.includes('getprop')) out = '36'
15
+ if (args.includes('--list_categories')) out = ' sched - Scheduling\n input - Input\n gfx - Graphics'
16
+ if (args.join(' ').includes('YQ==')) out = 'a'
17
+ if (args.join(' ').includes('for pid')) out = processes
18
+ if (args.includes('stat')) out = '1024'
19
+ return { exitCode: 0, stdout: out, stderr: '' }
20
+ }
21
+ const native = new AndroidTraceNative(execute)
22
+ const remote = '/data/misc/perfetto-traces/mcp-62a51510-c303-4a1b-af0e-f97cc976d740.perfetto-trace'
23
+ assert.equal((await native.capabilities('serial with spaces')).supported, true)
24
+ for (const probe of ['--help', '--query', 'getprop', '--list_categories', 'test -w', 'YQ==']) {
25
+ failure = probe
26
+ assert.equal((await native.capabilities('serial with spaces')).supported, false, probe)
27
+ }
28
+ failure = ''
29
+ await native.start('serial with spaces', remote, 1001, ['sched', 'input'])
30
+ assert.equal(calls.at(-1)!.timeout, 35000)
31
+ assert.ok(calls.at(-1)!.args.join(' ').includes('--background-wait -c -'))
32
+ assert.equal(await native.find('serial with spaces', remote), null)
33
+ processes = '123 456'
34
+ assert.deepEqual(await native.find('serial with spaces', remote), { pid: 123, startTime: '456' })
35
+ assert.ok(calls.at(-1)!.args.join(' ').includes('stat=$(cat "$p/stat" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }'), 'Unreadable process identity must fail closed')
36
+ processes = '123 456\n999 888'
37
+ await assert.rejects(() => native.find('serial with spaces', remote), /IDENTITY_UNAVAILABLE/)
38
+ const previous = calls.length
39
+ await assert.rejects(() => native.stop('serial with spaces', remote, { pid: 0, startTime: '4' }))
40
+ await assert.rejects(() => native.start('serial with spaces', '/tmp/other; killall perfetto', 1000, []))
41
+ assert.equal(calls.length, previous)
42
+ await native.stop('serial with spaces', remote, { pid: 123, startTime: '456' })
43
+ const signal = calls.at(-1)!.args.join(' ')
44
+ assert.ok(signal.indexOf('awk') < signal.indexOf('kill -TERM'))
45
+ assert.ok(signal.indexOf('grep -Fx') < signal.indexOf('kill -TERM'))
46
+ assert.ok(!signal.includes('killall'))
47
+ failure = 'pull'
48
+ await assert.rejects(() => native.pull('serial with spaces', remote, '/tmp/fixture'), /ACTION_TIMEOUT/)
49
+ assert.equal(calls.at(-1)!.timeout, 30000)
50
+ console.log('Android Perfetto adapter probes, targeting, guards and deadlines: PASS')
51
+ }
52
+ run().catch(error => { console.error(error); process.exitCode = 1 })