mobile-debug-mcp 0.10.0 → 0.12.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 (67) hide show
  1. package/README.md +20 -5
  2. package/dist/android/diagnostics.js +24 -0
  3. package/dist/android/interact.js +1 -145
  4. package/dist/android/manage.js +162 -0
  5. package/dist/android/observe.js +133 -88
  6. package/dist/android/run.js +187 -0
  7. package/dist/android/utils.js +137 -147
  8. package/dist/ios/interact.js +4 -175
  9. package/dist/ios/manage.js +169 -0
  10. package/dist/ios/observe.js +129 -13
  11. package/dist/ios/run.js +200 -0
  12. package/dist/ios/utils.js +138 -124
  13. package/dist/server.js +45 -17
  14. package/dist/tools/interact.js +21 -71
  15. package/dist/tools/manage.js +180 -0
  16. package/dist/tools/observe.js +23 -69
  17. package/dist/tools/run.js +180 -0
  18. package/dist/utils/diagnostics.js +25 -0
  19. package/docs/CHANGELOG.md +14 -0
  20. package/eslint.config.js +22 -1
  21. package/package.json +8 -5
  22. package/scripts/check-idb.js +83 -0
  23. package/scripts/check-idb.ts +73 -0
  24. package/scripts/idb-helper.ts +76 -0
  25. package/scripts/install-idb.js +88 -0
  26. package/scripts/install-idb.ts +90 -0
  27. package/scripts/run-ios-smoke.ts +34 -0
  28. package/scripts/run-ios-ui-tree-tap.ts +33 -0
  29. package/src/android/diagnostics.ts +23 -0
  30. package/src/android/interact.ts +2 -155
  31. package/src/android/manage.ts +157 -0
  32. package/src/android/observe.ts +129 -97
  33. package/src/android/utils.ts +147 -149
  34. package/src/ios/interact.ts +5 -181
  35. package/src/ios/manage.ts +164 -0
  36. package/src/ios/observe.ts +130 -14
  37. package/src/ios/utils.ts +127 -128
  38. package/src/server.ts +47 -17
  39. package/src/tools/interact.ts +23 -62
  40. package/src/tools/manage.ts +171 -0
  41. package/src/tools/observe.ts +24 -74
  42. package/src/types.ts +9 -0
  43. package/src/utils/diagnostics.ts +36 -0
  44. package/test/device/README.md +49 -0
  45. package/test/device/index.ts +27 -0
  46. package/test/device/manage/run-build-install-ios.ts +82 -0
  47. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  48. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  49. package/test/{integration → device/observe}/logstream-real.ts +5 -4
  50. package/test/{integration → device/utils}/test-dist.ts +2 -2
  51. package/test/unit/index.ts +10 -6
  52. package/test/unit/manage/build.test.ts +83 -0
  53. package/test/unit/manage/build_and_install.test.ts +134 -0
  54. package/test/unit/manage/diagnostics.test.ts +85 -0
  55. package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
  56. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  57. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
  58. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  59. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  60. package/tsconfig.json +2 -1
  61. package/test/integration/index.ts +0 -8
  62. package/test/integration/test-dist.mjs +0 -41
  63. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  64. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  65. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  66. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  67. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
@@ -1,11 +1,6 @@
1
- import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
- import { execAdb, getAndroidDeviceMetadata, getDeviceInfo, spawnAdb } from "./utils.js"
3
- import { detectJavaHome } from "../utils/java.js"
1
+ import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
+ import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
4
3
  import { AndroidObserve } from "./observe.js"
5
- import { promises as fs } from "fs"
6
- import { spawn } from "child_process"
7
- import path from "path"
8
- import { existsSync } from "fs"
9
4
 
10
5
 
11
6
  export class AndroidInteract {
@@ -93,152 +88,4 @@ export class AndroidInteract {
93
88
  }
94
89
  }
95
90
 
96
- async installApp(apkPath: string, deviceId?: string): Promise<import("../types.js").InstallAppResponse> {
97
- const metadata = await getAndroidDeviceMetadata("", deviceId)
98
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
99
-
100
- // Helper to recursively find first APK under a directory
101
- async function findApk(dir: string): Promise<string | undefined> {
102
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
103
- for (const e of entries) {
104
- const full = path.join(dir, e.name)
105
- if (e.isDirectory()) {
106
- const found = await findApk(full)
107
- if (found) return found
108
- } else if (e.isFile() && full.endsWith('.apk')) {
109
- return full
110
- }
111
- }
112
- return undefined
113
- }
114
-
115
- try {
116
- let apkToInstall = apkPath
117
-
118
- // If a directory is provided, attempt to build via Gradle
119
- const stat = await fs.stat(apkPath).catch(() => null)
120
- if (stat && stat.isDirectory()) {
121
- const gradlewPath = path.join(apkPath, 'gradlew')
122
- const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle'
123
-
124
- await new Promise<void>(async (resolve, reject) => {
125
- // Auto-detect and set JAVA_HOME (prefer JDK 17) so builds don't require manual environment setup
126
- const detectedJavaHome = await detectJavaHome().catch(() => undefined)
127
- const env = Object.assign({}, process.env)
128
- if (detectedJavaHome) {
129
- // Override existing JAVA_HOME if detection found a preferably compatible JDK (e.g., JDK 17).
130
- if (env.JAVA_HOME !== detectedJavaHome) {
131
- env.JAVA_HOME = detectedJavaHome
132
- // Also ensure the JDK bin is on PATH so tools like jlink/javac are resolved from the detected JDK
133
- env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
134
- console.debug('[android] Overriding JAVA_HOME with detected path:', detectedJavaHome)
135
- }
136
- }
137
-
138
- // Sanitize environment so user shell init scripts are less likely to override our JAVA_HOME.
139
- try {
140
- // Remove obvious shell profile hints; avoid touching SDKMAN symlinks or on-disk state.
141
- delete env.SHELL
142
- } catch {}
143
-
144
- // If we detected a compatible JDK, instruct Gradle to use it and avoid daemon reuse
145
- // Prepare gradle invocation
146
- const gradleArgs = ['assembleDebug']
147
- if (detectedJavaHome) {
148
- gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
149
- gradleArgs.push('--no-daemon')
150
- env.GRADLE_JAVA_HOME = detectedJavaHome
151
- }
152
-
153
- // Prefer invoking the wrapper directly without a shell to avoid user profile shims (sdkman) re-setting JAVA_HOME
154
- const wrapperPath = path.join(apkPath, 'gradlew')
155
- const useWrapper = existsSync(wrapperPath)
156
- const execCmd = useWrapper ? wrapperPath : gradleCmd
157
- const spawnOpts: any = { cwd: apkPath, env }
158
- // When using wrapper, ensure it's executable and invoke directly (no shell)
159
- if (useWrapper) {
160
- // Ensure the wrapper is executable; swallow errors from chmod (best-effort).
161
- await fs.chmod(wrapperPath, 0o755).catch(() => {})
162
- spawnOpts.shell = false
163
- } else {
164
- // if using system 'gradle' allow shell to resolve platform PATH
165
- spawnOpts.shell = true
166
- }
167
-
168
- const proc = spawn(execCmd, gradleArgs, spawnOpts)
169
- let stderr = ''
170
- proc.stderr?.on('data', d => stderr += d.toString())
171
- proc.on('close', code => {
172
- if (code === 0) resolve()
173
- else reject(new Error(stderr || `Gradle build failed with code ${code}`))
174
- })
175
- proc.on('error', err => reject(err))
176
- })
177
-
178
- const built = await findApk(apkPath)
179
- if (!built) throw new Error('Could not locate built APK after running Gradle')
180
- apkToInstall = built
181
- }
182
-
183
- // Try normal adb install with streaming attempt
184
- try {
185
- const res = await spawnAdb(['install', '-r', apkToInstall], deviceId)
186
- if (res.code === 0) {
187
- return { device: deviceInfo, installed: true, output: res.stdout }
188
- }
189
- // fallthrough to fallback
190
- } catch (e) {
191
- // Log and continue to fallback
192
- console.debug('[android] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e))
193
- }
194
-
195
- // Fallback: push APK to device and use pm install -r
196
- const basename = path.basename(apkToInstall)
197
- const remotePath = `/data/local/tmp/${basename}`
198
- await execAdb(['push', apkToInstall, remotePath], deviceId)
199
- const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
200
- // cleanup remote file
201
- try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
202
- return { device: deviceInfo, installed: true, output: pmOut }
203
- } catch (e) {
204
- return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) }
205
- }
206
- }
207
-
208
- async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
209
- const metadata = await getAndroidDeviceMetadata(appId, deviceId)
210
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
211
-
212
- await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
213
-
214
- return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
215
- }
216
-
217
- async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
218
- const metadata = await getAndroidDeviceMetadata(appId, deviceId)
219
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
220
-
221
- await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
222
-
223
- return { device: deviceInfo, appTerminated: true }
224
- }
225
-
226
- async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
227
- await this.terminateApp(appId, deviceId)
228
- const startResult = await this.startApp(appId, deviceId)
229
- return {
230
- device: startResult.device,
231
- appRestarted: startResult.appStarted,
232
- launchTimeMs: startResult.launchTimeMs
233
- }
234
- }
235
-
236
- async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
237
- const metadata = await getAndroidDeviceMetadata(appId, deviceId)
238
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
239
-
240
- const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
241
-
242
- return { device: deviceInfo, dataCleared: output === 'Success' }
243
- }
244
91
  }
@@ -0,0 +1,157 @@
1
+ import { promises as fs } from 'fs'
2
+ import { spawn } from 'child_process'
3
+ import path from 'path'
4
+ import { existsSync } from 'fs'
5
+ import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js'
6
+ import { execAdbWithDiagnostics } from './diagnostics.js'
7
+ import { detectJavaHome } from '../utils/java.js'
8
+ import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
9
+
10
+ export class AndroidManage {
11
+ async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
12
+ void _variant
13
+ try {
14
+ // Always use the shared prepareGradle utility for consistent env/setup
15
+ const { execCmd, gradleArgs, spawnOpts } = await (await import('./utils.js')).prepareGradle(projectPath)
16
+ await new Promise<void>((resolve, reject) => {
17
+ const proc = spawn(execCmd, gradleArgs, spawnOpts)
18
+ let stderr = ''
19
+ proc.stderr?.on('data', d => stderr += d.toString())
20
+ proc.on('close', code => {
21
+ if (code === 0) resolve()
22
+ else reject(new Error(stderr || `Gradle failed with code ${code}`))
23
+ })
24
+ proc.on('error', err => reject(err))
25
+ })
26
+
27
+ const apk = await findApk(projectPath)
28
+ if (!apk) return { error: 'Could not find APK after build' }
29
+ return { artifactPath: apk }
30
+ } catch (e) {
31
+ return { error: e instanceof Error ? e.message : String(e) }
32
+ }
33
+ }
34
+
35
+ async installApp(apkPath: string, deviceId?: string): Promise<InstallAppResponse> {
36
+ const metadata = await getAndroidDeviceMetadata('', deviceId)
37
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
38
+
39
+ let apkToInstall: string = apkPath
40
+ try {
41
+ const stat = await fs.stat(apkPath).catch(() => null)
42
+ if (stat && stat.isDirectory()) {
43
+ const detectedJavaHome = await detectJavaHome().catch(() => undefined)
44
+ const env = Object.assign({}, process.env)
45
+ if (detectedJavaHome) {
46
+ if (env.JAVA_HOME !== detectedJavaHome) {
47
+ env.JAVA_HOME = detectedJavaHome
48
+ env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
49
+ console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome)
50
+ }
51
+ }
52
+ try { delete env.SHELL } catch {}
53
+
54
+ const gradleArgs = ['assembleDebug']
55
+ if (detectedJavaHome) {
56
+ gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
57
+ gradleArgs.push('--no-daemon')
58
+ env.GRADLE_JAVA_HOME = detectedJavaHome
59
+ }
60
+
61
+ const wrapperPath = path.join(apkPath, 'gradlew')
62
+ const useWrapper = existsSync(wrapperPath)
63
+ const execCmd = useWrapper ? wrapperPath : 'gradle'
64
+ const spawnOpts: any = { cwd: apkPath, env }
65
+ if (useWrapper) {
66
+ await fs.chmod(wrapperPath, 0o755).catch(() => {})
67
+ spawnOpts.shell = false
68
+ } else spawnOpts.shell = true
69
+
70
+ const proc = spawn(execCmd, gradleArgs, spawnOpts)
71
+ let stderr = ''
72
+ await new Promise<void>((resolve, reject) => {
73
+ proc.stderr?.on('data', d => stderr += d.toString())
74
+ proc.on('close', code => {
75
+ if (code === 0) resolve()
76
+ else reject(new Error(stderr || `Gradle build failed with code ${code}`))
77
+ })
78
+ proc.on('error', err => reject(err))
79
+ })
80
+
81
+ const built = await findApk(apkPath)
82
+ if (!built) throw new Error('Could not locate built APK after running Gradle')
83
+ apkToInstall = built
84
+ }
85
+
86
+ try {
87
+ const res = await spawnAdb(['install', '-r', apkToInstall], deviceId)
88
+ if (res.code === 0) {
89
+ return { device: deviceInfo, installed: true, output: res.stdout }
90
+ }
91
+ } catch (e) {
92
+ console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e))
93
+ }
94
+
95
+ const basename = path.basename(apkToInstall)
96
+ const remotePath = `/data/local/tmp/${basename}`
97
+ await execAdb(['push', apkToInstall, remotePath], deviceId)
98
+ const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
99
+ try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
100
+ return { device: deviceInfo, installed: true, output: pmOut }
101
+ } catch (e) {
102
+ // gather diagnostics for attempted adb operations
103
+ const basename = path.basename(apkToInstall)
104
+ const remotePath = `/data/local/tmp/${basename}`
105
+ const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId)
106
+ const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId)
107
+ const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId)
108
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } }
109
+ }
110
+ }
111
+
112
+ async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
113
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId)
114
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
115
+ try {
116
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
117
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
118
+ } catch (e:any) {
119
+ const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
120
+ return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
121
+ }
122
+ }
123
+
124
+ async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
125
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId)
126
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
127
+ try {
128
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
129
+ return { device: deviceInfo, appTerminated: true }
130
+ } catch (e:any) {
131
+ const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
132
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
133
+ }
134
+ }
135
+
136
+ async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
137
+ await this.terminateApp(appId, deviceId)
138
+ const startResult = await this.startApp(appId, deviceId)
139
+ return {
140
+ device: startResult.device,
141
+ appRestarted: startResult.appStarted,
142
+ launchTimeMs: startResult.launchTimeMs
143
+ }
144
+ }
145
+
146
+ async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
147
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId)
148
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
149
+ try {
150
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
151
+ return { device: deviceInfo, dataCleared: output === 'Success' }
152
+ } catch (e:any) {
153
+ const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId)
154
+ return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
155
+ }
156
+ }
157
+ }
@@ -1,104 +1,14 @@
1
1
  import { spawn } from "child_process"
2
2
  import { XMLParser } from "fast-xml-parser"
3
3
  import { GetLogsResponse, CaptureAndroidScreenResponse, GetUITreeResponse, GetCurrentScreenResponse, UIElement, DeviceInfo } from "../types.js"
4
- import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
4
+ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js"
5
+ import { createWriteStream } from "fs"
6
+ import { promises as fsPromises } from "fs"
7
+ import path from "path"
5
8
 
6
- // --- Helper Functions Specific to Observe ---
9
+ const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
7
10
 
8
- const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
9
11
 
10
- function parseBounds(bounds: string): [number, number, number, number] {
11
- const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
12
- if (match) {
13
- return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
14
- }
15
- return [0, 0, 0, 0];
16
- }
17
-
18
- function getCenter(bounds: [number, number, number, number]): [number, number] {
19
- const [x1, y1, x2, y2] = bounds;
20
- return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
21
- }
22
-
23
- async function getScreenResolution(deviceId?: string): Promise<{ width: number; height: number }> {
24
- try {
25
- const output = await execAdb(['shell', 'wm', 'size'], deviceId);
26
- const match = output.match(/Physical size: (\d+)x(\d+)/);
27
- if (match) {
28
- return { width: parseInt(match[1]), height: parseInt(match[2]) };
29
- }
30
- } catch {
31
- // ignore
32
- }
33
- return { width: 0, height: 0 };
34
- }
35
-
36
- function traverseNode(node: any, elements: UIElement[], parentIndex: number = -1, depth: number = 0): number {
37
- if (!node) return -1;
38
-
39
- let currentIndex = -1;
40
-
41
- // Check if it's a valid node with attributes we care about
42
- if (node['@_class']) {
43
- const text = node['@_text'] || null;
44
- const contentDescription = node['@_content-desc'] || null;
45
- const clickable = node['@_clickable'] === 'true';
46
- const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
47
-
48
- // Filtering Logic:
49
- // Keep if clickable OR has visible text OR has content description
50
- const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
51
-
52
- if (isUseful) {
53
- const element: UIElement = {
54
- text,
55
- contentDescription,
56
- type: node['@_class'] || 'unknown',
57
- resourceId: node['@_resource-id'] || null,
58
- clickable,
59
- enabled: node['@_enabled'] === 'true',
60
- visible: true,
61
- bounds,
62
- center: getCenter(bounds),
63
- depth
64
- };
65
-
66
- if (parentIndex !== -1) {
67
- element.parentId = parentIndex;
68
- }
69
-
70
- elements.push(element);
71
- currentIndex = elements.length - 1;
72
- }
73
- }
74
-
75
- // If current node was skipped (not useful or no class), children inherit parentIndex
76
- // If current node was added, children use currentIndex
77
- const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
78
- const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
79
-
80
- const childrenIndices: number[] = [];
81
-
82
- // Traverse children
83
- if (node.node) {
84
- if (Array.isArray(node.node)) {
85
- node.node.forEach((child: any) => {
86
- const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
87
- if (childIndex !== -1) childrenIndices.push(childIndex);
88
- });
89
- } else {
90
- const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
91
- if (childIndex !== -1) childrenIndices.push(childIndex);
92
- }
93
- }
94
-
95
- // Update current element with children if it was added
96
- if (currentIndex !== -1 && childrenIndices.length > 0) {
97
- elements[currentIndex].children = childrenIndices;
98
- }
99
-
100
- return currentIndex;
101
- }
102
12
 
103
13
  export class AndroidObserve {
104
14
  async getDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
@@ -171,7 +81,7 @@ export class AndroidObserve {
171
81
  elements
172
82
  };
173
83
  } catch (e) {
174
- const errorMessage = `Failed to get UI tree. ADB Path: '${ADB}'. Error: ${e instanceof Error ? e.message : String(e)}`;
84
+ const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
175
85
  console.error(errorMessage);
176
86
  return {
177
87
  device: deviceInfo,
@@ -226,7 +136,7 @@ export class AndroidObserve {
226
136
  const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
227
137
 
228
138
  // Using spawn for screencap as well to ensure consistent process handling
229
- const child = spawn(ADB, args)
139
+ const child = spawn(getAdbCmd(), args)
230
140
 
231
141
  const chunks: Buffer[] = []
232
142
  let stderr = ''
@@ -357,4 +267,126 @@ export class AndroidObserve {
357
267
  };
358
268
  }
359
269
  }
270
+
271
+ async startLogStream(packageName: string, level: 'error' | 'warn' | 'info' | 'debug' = 'error', deviceId?: string, sessionId: string = 'default') {
272
+ try {
273
+ const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '')
274
+ const pid = (pidOutput || '').trim()
275
+ if (!pid) return { success: false, error: 'app_not_running' }
276
+
277
+ const levelMap: Record<string, string> = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' }
278
+ const filter = levelMap[level] || levelMap['error']
279
+
280
+ if (activeLogStreams.has(sessionId)) {
281
+ try { activeLogStreams.get(sessionId)!.proc.kill() } catch {}
282
+ activeLogStreams.delete(sessionId)
283
+ }
284
+
285
+ const args = ['logcat', `--pid=${pid}`, filter]
286
+ const proc = spawn(getAdbCmd(), args)
287
+
288
+ const tmpDir = process.env.TMPDIR || '/tmp'
289
+ const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
290
+ const stream = createWriteStream(file, { flags: 'a' })
291
+
292
+ proc.stdout.on('data', (chunk) => {
293
+ const text = chunk.toString()
294
+ const lines = text.split(/\r?\n/).filter(Boolean)
295
+ for (const l of lines) {
296
+ const entry = parseLogLine(l)
297
+ stream.write(JSON.stringify(entry) + '\n')
298
+ }
299
+ })
300
+
301
+ proc.stderr.on('data', (chunk) => {
302
+ const text = chunk.toString()
303
+ const lines = text.split(/\r?\n/).filter(Boolean)
304
+ for (const l of lines) {
305
+ const entry = { timestamp: '', level: 'E', tag: 'adb', message: l }
306
+ stream.write(JSON.stringify(entry) + '\n')
307
+ }
308
+ })
309
+
310
+ proc.on('close', () => {
311
+ stream.end()
312
+ activeLogStreams.delete(sessionId)
313
+ })
314
+
315
+ activeLogStreams.set(sessionId, { proc, file })
316
+ return { success: true, stream_started: true }
317
+ } catch (e) {
318
+ return { success: false, error: e instanceof Error ? e.message : String(e) }
319
+ }
320
+ }
321
+
322
+ async stopLogStream(sessionId: string = 'default') {
323
+ const entry = activeLogStreams.get(sessionId)
324
+ if (!entry) return { success: true }
325
+ try { entry.proc.kill() } catch {}
326
+ activeLogStreams.delete(sessionId)
327
+ return { success: true }
328
+ }
329
+
330
+ async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string) {
331
+ // Prefer active stream if present, otherwise fall back to a well-known NDJSON file for the session
332
+ const entry = activeLogStreams.get(sessionId)
333
+ let file: string | undefined
334
+ if (entry && entry.file) file = entry.file
335
+ else {
336
+ const tmpDir = process.env.TMPDIR || '/tmp'
337
+ const candidate = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
338
+ file = candidate
339
+ }
340
+
341
+ try {
342
+ const data = await fsPromises.readFile(file, 'utf8').catch(() => '')
343
+ if (!data) return { entries: [], crash_summary: { crash_detected: false } }
344
+ const lines = data.split(/\r?\n/).filter(Boolean)
345
+
346
+ const parsed = lines.map(l => {
347
+ try {
348
+ const obj: any = JSON.parse(l)
349
+ if (typeof obj._iso === 'undefined') {
350
+ let iso: string | null = null
351
+ if (obj.timestamp) {
352
+ const d = new Date(obj.timestamp)
353
+ if (!isNaN(d.getTime())) iso = d.toISOString()
354
+ }
355
+ obj._iso = iso
356
+ }
357
+ if (typeof obj.crash === 'undefined') {
358
+ const msg = (obj.message || '').toString()
359
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/)
360
+ if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
361
+ obj.crash = true
362
+ if (exMatch) obj.exception = exMatch[1]
363
+ } else {
364
+ obj.crash = false
365
+ }
366
+ }
367
+ return obj
368
+ } catch {
369
+ return { message: l, _iso: null, crash: false }
370
+ }
371
+ })
372
+
373
+ let filtered = parsed
374
+ if (since) {
375
+ let sinceMs: number | null = null
376
+ if (/^\d+$/.test(since)) sinceMs = Number(since)
377
+ else {
378
+ const sDate = new Date(since)
379
+ if (!isNaN(sDate.getTime())) sinceMs = sDate.getTime()
380
+ }
381
+ if (sinceMs !== null) filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs))
382
+ }
383
+
384
+ const entries = filtered.slice(-Math.max(0, limit))
385
+ const crashEntry = entries.find(e => e.crash)
386
+ const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false }
387
+ return { entries, crash_summary }
388
+ } catch {
389
+ return { entries: [], crash_summary: { crash_detected: false } }
390
+ }
391
+ }
360
392
  }