mobile-debug-mcp 0.32.4 → 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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +21 -10
- package/README.md +69 -4
- package/agent-plugin/.claude-plugin/plugin.json +20 -0
- package/agent-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-plugin/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/README.md +9 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
- 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/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +70 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +280 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +124 -0
- package/dist/observe/readiness.js +88 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +4 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +51 -0
- package/dist/server/tool-handlers.js +294 -105
- 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/ios/utils.js +4 -4
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +26 -2
- package/dist/utils/resolve-device.js +9 -0
- package/docs/CHANGELOG.md +15 -0
- package/docs/specs/020-conditional-actions/intent.md +62 -0
- package/docs/specs/020-conditional-actions/plan.md +214 -0
- package/docs/specs/020-conditional-actions/spec.md +301 -0
- package/docs/specs/021-app-readiness-signals/intent.md +40 -0
- package/docs/specs/021-app-readiness-signals/plan.md +102 -0
- package/docs/specs/021-app-readiness-signals/spec.md +144 -0
- package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
- package/docs/specs/022-correlated-evidence/intent.md +37 -0
- package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
- package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
- package/docs/specs/022-correlated-evidence/plan.md +171 -0
- package/docs/specs/022-correlated-evidence/spec.md +123 -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/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +5 -0
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +82 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +70 -0
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- 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/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +57 -28
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +262 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +94 -0
- package/src/observe/readiness.ts +84 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +4 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +49 -0
- package/src/server/tool-handlers.ts +182 -21
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/types.ts +30 -1
- package/src/utils/exec.ts +2 -0
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +24 -2
- package/src/utils/resolve-device.ts +10 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -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/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -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/android_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
- package/test/unit/observe/compact_tree_error.test.ts +24 -0
- package/test/unit/observe/conditional_snapshots.test.ts +33 -0
- package/test/unit/observe/correlated-evidence.test.ts +128 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +41 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- 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/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/worker/DebuggerWorker.java +160 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
- /package/{skills → .agents/skills}/README.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
- /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
package/src/types.ts
CHANGED
|
@@ -218,6 +218,7 @@ export interface UIElement {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
export interface GetUITreeResponse {
|
|
221
|
+
readiness?: ReadinessSummary;
|
|
221
222
|
device: DeviceInfo;
|
|
222
223
|
screen: string;
|
|
223
224
|
resolution: {
|
|
@@ -226,12 +227,27 @@ export interface GetUITreeResponse {
|
|
|
226
227
|
};
|
|
227
228
|
elements: UIElement[];
|
|
228
229
|
snapshot_revision: number;
|
|
230
|
+
snapshot_session_id?: string;
|
|
229
231
|
captured_at_ms: number;
|
|
230
232
|
snapshot_delta?: SnapshotDelta | null;
|
|
231
233
|
loading_state?: LoadingState | null;
|
|
232
234
|
error?: string;
|
|
233
235
|
}
|
|
234
236
|
|
|
237
|
+
export interface ReadinessSummary {
|
|
238
|
+
state: 'loading' | 'transitioning' | 'settled' | 'unknown' | 'unresponsive' | 'debugger_paused';
|
|
239
|
+
scope: 'device_ui';
|
|
240
|
+
basis: 'observed' | 'heuristic' | 'insufficient';
|
|
241
|
+
captured_at_ms: number | null;
|
|
242
|
+
sample_count: number;
|
|
243
|
+
stable_for_ms: number;
|
|
244
|
+
snapshot_session_id: string | null;
|
|
245
|
+
snapshot_revision: number | null;
|
|
246
|
+
evidence: Array<{ code: string; source: string; captured_at_ms: number | null; snapshot_revision: number | null }>;
|
|
247
|
+
limitations: string[];
|
|
248
|
+
capabilities: { loading: boolean; transitioning: boolean; settled: boolean; unresponsive: boolean; debugger_paused: boolean };
|
|
249
|
+
}
|
|
250
|
+
|
|
235
251
|
export interface GetCurrentScreenResponse {
|
|
236
252
|
device: DeviceInfo;
|
|
237
253
|
package: string;
|
|
@@ -289,6 +305,8 @@ export interface TapResponse {
|
|
|
289
305
|
}
|
|
290
306
|
|
|
291
307
|
export type ActionFailureCode =
|
|
308
|
+
| 'PRECONDITION_FAILED'
|
|
309
|
+
| 'PRECONDITION_UNAVAILABLE'
|
|
292
310
|
| 'ELEMENT_NOT_FOUND'
|
|
293
311
|
| 'ELEMENT_NOT_INTERACTABLE'
|
|
294
312
|
| 'TIMEOUT'
|
|
@@ -417,6 +435,15 @@ export interface TimingBreakdown {
|
|
|
417
435
|
serialization_ms?: number;
|
|
418
436
|
}
|
|
419
437
|
|
|
438
|
+
export interface ActionConditionResult {
|
|
439
|
+
status: 'matched' | 'mismatch' | 'unavailable' | 'not_checked';
|
|
440
|
+
expected: { snapshot_session_id: string; snapshot_revision: number };
|
|
441
|
+
observed: GetUITreeResponse | null;
|
|
442
|
+
elapsed_ms: number;
|
|
443
|
+
reason?: 'BASELINE_UNAVAILABLE' | 'SESSION_MISMATCH' | 'DEVICE_MISMATCH' |
|
|
444
|
+
'OBSERVATION_FAILED' | 'OBSERVATION_INVALID' | 'CONDITION_TIMEOUT' | 'ACTION_NOT_REACHED';
|
|
445
|
+
}
|
|
446
|
+
|
|
420
447
|
export interface ActionExecutionResult {
|
|
421
448
|
action_id: string;
|
|
422
449
|
timestamp: string;
|
|
@@ -435,6 +462,7 @@ export interface ActionExecutionResult {
|
|
|
435
462
|
dispatch_started?: boolean;
|
|
436
463
|
verification?: { mode: 'none' | 'light' | 'full'; status: 'not_requested' | 'observed' | 'verified' | 'unavailable' | 'failed' };
|
|
437
464
|
state_delta?: StateDelta;
|
|
465
|
+
condition?: ActionConditionResult;
|
|
438
466
|
timing?: TimingBreakdown;
|
|
439
467
|
recovery?: RecoveryState;
|
|
440
468
|
trace: ActionTrace;
|
|
@@ -535,6 +563,7 @@ export interface AdjustControlResponse extends ActionExecutionResult {
|
|
|
535
563
|
}
|
|
536
564
|
|
|
537
565
|
export interface WaitForUIChangeResponse {
|
|
566
|
+
readiness?: ReadinessSummary;
|
|
538
567
|
success: boolean;
|
|
539
568
|
observed_change: 'hierarchy_diff' | 'text_change' | 'state_change' | null;
|
|
540
569
|
snapshot_revision?: number;
|
|
@@ -554,7 +583,7 @@ export interface WaitForUIChangeResponse {
|
|
|
554
583
|
reason?: string;
|
|
555
584
|
loading_state?: LoadingState | null;
|
|
556
585
|
error?: {
|
|
557
|
-
code: 'INVALID_SCOPE' | 'ELEMENT_NOT_FOUND';
|
|
586
|
+
code: 'INVALID_SCOPE' | 'ELEMENT_NOT_FOUND' | 'TIMEOUT' | 'DEVICE_UNAVAILABLE';
|
|
558
587
|
message: string;
|
|
559
588
|
};
|
|
560
589
|
}
|
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/ios/utils.ts
CHANGED
|
@@ -62,7 +62,7 @@ export async function isIDBInstalled(): Promise<boolean> {
|
|
|
62
62
|
const cmd = getIdbCmd()
|
|
63
63
|
if (!cmd) return false
|
|
64
64
|
try {
|
|
65
|
-
execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','pipe'], timeout: 5000 })
|
|
65
|
+
execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','pipe'], timeout: remainingBudget(5000), killSignal: 'SIGKILL' })
|
|
66
66
|
return true
|
|
67
67
|
} catch (e: unknown) {
|
|
68
68
|
console.debug(`[isIDBInstalled] idb probe failed for '${cmd}': ${e instanceof Error ? e.message : String(e)}`)
|
|
@@ -200,7 +200,7 @@ export async function findAppBundle(dir: string): Promise<string | undefined> {
|
|
|
200
200
|
export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
|
|
201
201
|
return new Promise((resolve) => {
|
|
202
202
|
// If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
|
|
203
|
-
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
203
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err, stdout) => {
|
|
204
204
|
const fallback: DeviceInfo = {
|
|
205
205
|
platform: "ios",
|
|
206
206
|
id: deviceId,
|
|
@@ -247,7 +247,7 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
|
|
|
247
247
|
export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
|
|
248
248
|
return new Promise((resolve) => {
|
|
249
249
|
// Query all devices and separately query booted devices to mark them
|
|
250
|
-
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
250
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err, stdout) => {
|
|
251
251
|
if (err || !stdout) return resolve([])
|
|
252
252
|
try {
|
|
253
253
|
const data = JSON.parse(stdout)
|
|
@@ -256,7 +256,7 @@ export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
|
|
|
256
256
|
const checks: Promise<void>[] = []
|
|
257
257
|
|
|
258
258
|
// Get booted devices set
|
|
259
|
-
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
|
|
259
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err2, stdout2) => {
|
|
260
260
|
const bootedSet = new Set<string>()
|
|
261
261
|
if (!err2 && stdout2) {
|
|
262
262
|
try {
|
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 }
|
|
@@ -10,12 +11,33 @@ export function runWithBudget<T>(timeoutMs: number, operation: (budget: Operatio
|
|
|
10
11
|
}
|
|
11
12
|
export function remainingBudget(fallback = 0): number {
|
|
12
13
|
const budget = budgets.getStore()
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
let deadline = budget?.deadline ?? Infinity
|
|
15
|
+
for (let ancestor = budget?.parent; ancestor; ancestor = ancestor.parent) deadline = Math.min(deadline, ancestor.deadline)
|
|
16
|
+
if (!Number.isFinite(deadline)) return fallback
|
|
17
|
+
const remaining = Math.floor(deadline - performance.now())
|
|
15
18
|
if (remaining <= 0) throw new Error('ACTION_TIMEOUT')
|
|
16
19
|
return fallback > 0 ? Math.min(fallback, remaining) : remaining
|
|
17
20
|
}
|
|
21
|
+
|
|
22
|
+
// Pause only this action's budget. Any enclosing operation still bounds the read.
|
|
23
|
+
export async function runWithSeparateBudget<T>(timeoutMs: number, operation: () => Promise<T>): Promise<T> {
|
|
24
|
+
remainingBudget()
|
|
25
|
+
const action = budgets.getStore()
|
|
26
|
+
const started = performance.now()
|
|
27
|
+
const parent = action?.parent
|
|
28
|
+
const budget: OperationBudget = { deadline: Math.min(parent?.deadline ?? Infinity, started + timeoutMs), dispatchStarted: false, parent }
|
|
29
|
+
try {
|
|
30
|
+
return await budgets.run(budget, async () => {
|
|
31
|
+
const result = await operation()
|
|
32
|
+
remainingBudget()
|
|
33
|
+
return result
|
|
34
|
+
})
|
|
35
|
+
} finally {
|
|
36
|
+
if (action) action.deadline += performance.now() - started
|
|
37
|
+
}
|
|
38
|
+
}
|
|
18
39
|
export function markDispatchStarted() {
|
|
40
|
+
checkDebuggerGuard(true)
|
|
19
41
|
remainingBudget()
|
|
20
42
|
let budget = budgets.getStore()
|
|
21
43
|
while (budget) { budget.dispatchStarted = true; budget = budget.parent }
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
2
|
+
import { bindEvidenceDevice } from '../observe/evidence.js'
|
|
1
3
|
import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js'
|
|
2
4
|
import { measure } from './timing.js'
|
|
3
5
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
@@ -50,10 +52,18 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
|
|
|
50
52
|
const deviceContext = new AsyncLocalStorage<DeviceInfo>()
|
|
51
53
|
|
|
52
54
|
export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
|
|
55
|
+
bindEvidenceDevice(device)
|
|
53
56
|
return deviceContext.run(device, operation)
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceInfo> {
|
|
60
|
+
checkDebuggerGuard()
|
|
61
|
+
const device = await resolveDevice(opts)
|
|
62
|
+
bindEvidenceDevice(device)
|
|
63
|
+
return device
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function resolveDevice(opts: ResolveOptions): Promise<DeviceInfo> {
|
|
57
67
|
const { platform, appId, prefer, deviceId } = opts
|
|
58
68
|
const current = deviceContext.getStore()
|
|
59
69
|
if (current && current.platform === platform && (!deviceId || current.id === deviceId)) return current
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
4
|
+
|
|
5
|
+
// Exercises the rebuilt stdio server, not the connected/published MCP instance.
|
|
6
|
+
async function run() {
|
|
7
|
+
if (process.env.SKIP_DEVICE_TESTS === '1') {
|
|
8
|
+
console.log('Correlated evidence device smoke: SKIPPED (SKIP_DEVICE_TESTS=1)')
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
const client = new Client({ name: 'correlated-evidence-smoke', version: '1.0.0' })
|
|
12
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
13
|
+
const parse = (response: any) => JSON.parse(response.content.find((item: any) => item.type === 'text').text)
|
|
14
|
+
const call = (name: string, args: Record<string, unknown> = {}) => client.callTool({ name, arguments: args }, undefined, { timeout: 120000 })
|
|
15
|
+
try {
|
|
16
|
+
await client.connect(transport)
|
|
17
|
+
const discovery = parse(await call('list_devices'))
|
|
18
|
+
for (const platform of ['android', 'ios'] as const) {
|
|
19
|
+
const device = discovery.devices.find((d: any) => d.platform === platform && d.simulator &&
|
|
20
|
+
(platform === 'android' || d.booted === true))
|
|
21
|
+
assert.ok(device, `A running ${platform} emulator/simulator is required`)
|
|
22
|
+
const target = { platform, deviceId: device.id }
|
|
23
|
+
const appId = platform === 'android' ? 'com.android.settings' : 'com.apple.Preferences'
|
|
24
|
+
const action = parse(await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' }))
|
|
25
|
+
assert.equal(action.success, true, JSON.stringify(action))
|
|
26
|
+
assert.equal(action.action_id, action.evidence.action_id)
|
|
27
|
+
assert.ok(action.evidence.dispatch_started_at_ms)
|
|
28
|
+
assert.ok(action.evidence.dispatch_completed_at_ms >= action.evidence.dispatch_started_at_ms)
|
|
29
|
+
assert.equal(action.evidence.verification_completed_at_ms, null)
|
|
30
|
+
const actionRef = { sessionId: action.evidence.session_id, actionId: action.evidence.action_id }
|
|
31
|
+
const screenResponse: any = await call('capture_screenshot', { ...target, actionRef })
|
|
32
|
+
const screen = parse(screenResponse)
|
|
33
|
+
assert.equal(screen.evidence.action_id, action.action_id)
|
|
34
|
+
assert.equal(screen.evidence.correlation_id, action.evidence.correlation_id)
|
|
35
|
+
assert.equal(screen.evidence.association, 'explicit')
|
|
36
|
+
assert.ok(screenResponse.content.some((item: any) => item.type === 'image' && item.data.length > 0))
|
|
37
|
+
const screenshot = screen.evidence.observations.find((v: any) => v.kind === 'screenshot')
|
|
38
|
+
assert.equal(screenshot.status, 'captured')
|
|
39
|
+
assert.equal(screenshot.freshness, 'fresh')
|
|
40
|
+
assert.ok(screenshot.capture_started_at_ms >= action.evidence.dispatch_completed_at_ms)
|
|
41
|
+
const logsResponse: any = await call('get_logs', { ...target, appId, limit: 5, actionRef })
|
|
42
|
+
const logs = parse(logsResponse)
|
|
43
|
+
assert.equal(logs.evidence.action_id, action.action_id)
|
|
44
|
+
assert.ok(Array.isArray(JSON.parse(logsResponse.content[1].text).logs))
|
|
45
|
+
const log = logs.evidence.observations.find((v: any) => v.kind === 'logs')
|
|
46
|
+
assert.equal(log.status, 'captured', log.error)
|
|
47
|
+
assert.equal(log.event_time_comparable, false)
|
|
48
|
+
assert.equal(log.freshness, 'unknown')
|
|
49
|
+
const snapshot = parse(await call('capture_debug_snapshot', { ...target, actionRef, includeScreenshot: false, includeLogs: false }))
|
|
50
|
+
assert.equal(snapshot.evidence.action_id, action.action_id)
|
|
51
|
+
assert.equal(snapshot.raw.screenshot, null)
|
|
52
|
+
assert.equal(snapshot.evidence.observations.find((v: any) => v.kind === 'screenshot').status, 'omitted')
|
|
53
|
+
assert.equal(snapshot.evidence.observations.find((v: any) => v.kind === 'logs').status, 'omitted')
|
|
54
|
+
assert.ok(snapshot.evidence.observations.every((v: any) => !v.device || v.device.id === device.id))
|
|
55
|
+
const tree = snapshot.evidence.observations.find((v: any) => v.kind === 'ui_tree')
|
|
56
|
+
assert.equal(tree.status, 'captured', tree.error)
|
|
57
|
+
const unassociated = parse(await call('get_ui_tree', { ...target, responseMode: 'compact', actionRef: { ...actionRef, sessionId: 'previous-process' } }))
|
|
58
|
+
assert.equal(unassociated.evidence.association_reason, 'SESSION_MISMATCH')
|
|
59
|
+
assert.equal(unassociated.evidence.action_id, null)
|
|
60
|
+
console.log(JSON.stringify({ status: 'PASS', server: client.getServerVersion(), platform, device: device.id,
|
|
61
|
+
osVersion: device.osVersion, startingApp: appId, actionId: action.action_id,
|
|
62
|
+
phases: { dispatchStarted: action.evidence.dispatch_started_at_ms, dispatchCompleted: action.evidence.dispatch_completed_at_ms,
|
|
63
|
+
screenshotStarted: screenshot.capture_started_at_ms, screenshotCompleted: screenshot.capture_completed_at_ms },
|
|
64
|
+
screenshot: screenshot.status, logs: log.status, uiTree: tree.status,
|
|
65
|
+
disabledComponents: 'omitted', foreignReference: 'unavailable',
|
|
66
|
+
cleanup: 'No settings, app data or streams changed; Settings remains foreground. No content artifacts saved.' }))
|
|
67
|
+
}
|
|
68
|
+
} finally { await client.close() }
|
|
69
|
+
}
|
|
70
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|