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,9 +1,47 @@
1
1
  import { spawn } from 'child_process'
2
- import { DeviceInfo } from "../types.js"
3
- import { createWriteStream, promises as fsPromises } from 'fs'
2
+ import { DeviceInfo, UIElement } from "../types.js"
3
+ import { promises as fsPromises, existsSync } from 'fs'
4
4
  import path from 'path'
5
+ import { detectJavaHome } from '../utils/java.js'
5
6
 
6
- export const ADB = process.env.ADB_PATH || 'adb'
7
+ export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
8
+
9
+ /**
10
+ * Prepare Gradle execution options for building an Android project.
11
+ * Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
12
+ */
13
+ export async function prepareGradle(projectPath: string): Promise<{ execCmd: string, gradleArgs: string[], spawnOpts: any }> {
14
+ const gradlewPath = path.join(projectPath, 'gradlew')
15
+ const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle'
16
+ const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd
17
+
18
+ const gradleArgs: string[] = ['assembleDebug']
19
+
20
+ const detectedJavaHome = await detectJavaHome().catch(() => undefined)
21
+ const env = Object.assign({}, process.env)
22
+ if (detectedJavaHome) {
23
+ if (env.JAVA_HOME !== detectedJavaHome) {
24
+ env.JAVA_HOME = detectedJavaHome
25
+ env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
26
+ }
27
+ gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
28
+ gradleArgs.push('--no-daemon')
29
+ env.GRADLE_JAVA_HOME = detectedJavaHome
30
+ }
31
+
32
+ try { delete env.SHELL } catch {}
33
+
34
+ const useWrapper = existsSync(gradlewPath)
35
+ const spawnOpts: any = { cwd: projectPath, env }
36
+ if (useWrapper) {
37
+ try { await fsPromises.chmod(gradlewPath, 0o755) } catch {}
38
+ spawnOpts.shell = false
39
+ } else {
40
+ spawnOpts.shell = true
41
+ }
42
+
43
+ return { execCmd, gradleArgs, spawnOpts }
44
+ }
7
45
 
8
46
 
9
47
  // Helper to construct ADB args with optional device ID
@@ -40,7 +78,7 @@ export function execAdb(args: string[], deviceId?: string, options: SpawnOptions
40
78
  const { timeout: customTimeout, ...spawnOptions } = options;
41
79
 
42
80
  // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
43
- const child = spawn(ADB, adbArgs, spawnOptions)
81
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
44
82
 
45
83
  let stdout = ''
46
84
  let stderr = ''
@@ -88,7 +126,7 @@ export function spawnAdb(args: string[], deviceId?: string, options: SpawnOption
88
126
  const adbArgs = getAdbArgs(args, deviceId)
89
127
  return new Promise((resolve, reject) => {
90
128
  const { timeout: customTimeout, ...spawnOptions } = options
91
- const child = spawn(ADB, adbArgs, spawnOptions)
129
+ const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
92
130
 
93
131
  let stdout = ''
94
132
  let stderr = ''
@@ -161,6 +199,21 @@ export async function getAndroidDeviceMetadata(appId: string, deviceId?: string)
161
199
  }
162
200
  }
163
201
 
202
+ export async function findApk(dir: string): Promise<string | undefined> {
203
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => [])
204
+ for (const e of entries) {
205
+ const full = path.join(dir, e.name)
206
+ if (e.isDirectory()) {
207
+ const found = await findApk(full)
208
+ if (found) return found
209
+ } else if (e.isFile() && full.endsWith('.apk')) {
210
+ return full
211
+ }
212
+ }
213
+ return undefined
214
+ }
215
+
216
+
164
217
  export async function listAndroidDevices(appId?: string): Promise<DeviceInfo[]> {
165
218
  try {
166
219
  const devicesOutput = await execAdb(['devices', '-l'])
@@ -198,22 +251,101 @@ export async function listAndroidDevices(appId?: string): Promise<DeviceInfo[]>
198
251
  }
199
252
  }
200
253
 
201
- // Log stream management (one stream per session)
254
+ // UI helper utilities shared by observe/interact
255
+ export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
202
256
 
203
- const activeLogStreams: Map<string, { proc: { kill: () => void } | ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
257
+ export function parseBounds(bounds: string): [number, number, number, number] {
258
+ const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
259
+ if (match) {
260
+ return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
261
+ }
262
+ return [0, 0, 0, 0];
263
+ }
264
+
265
+ export function getCenter(bounds: [number, number, number, number]): [number, number] {
266
+ const [x1, y1, x2, y2] = bounds;
267
+ return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
268
+ }
204
269
 
205
- // Test helper to register a pre-existing NDJSON file as the active stream for a session (used by unit tests)
206
- export function _setActiveLogStream(sessionId: string, file: string) {
207
- activeLogStreams.set(sessionId, { proc: { kill: () => {} }, file })
270
+ export async function getScreenResolution(deviceId?: string): Promise<{ width: number; height: number }> {
271
+ try {
272
+ const output = await execAdb(['shell', 'wm', 'size'], deviceId);
273
+ const match = output.match(/Physical size: (\d+)x(\d+)/);
274
+ if (match) {
275
+ return { width: parseInt(match[1]), height: parseInt(match[2]) };
276
+ }
277
+ } catch {
278
+ // ignore
279
+ }
280
+ return { width: 0, height: 0 };
208
281
  }
209
282
 
210
- export function _clearActiveLogStream(sessionId: string) {
211
- activeLogStreams.delete(sessionId)
283
+ export function traverseNode(node: any, elements: UIElement[], parentIndex: number = -1, depth: number = 0): number {
284
+ if (!node) return -1;
285
+
286
+ let currentIndex = -1;
287
+
288
+ if (node['@_class']) {
289
+ const text = node['@_text'] || null;
290
+ const contentDescription = node['@_content-desc'] || null;
291
+ const clickable = node['@_clickable'] === 'true';
292
+ const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
293
+
294
+ const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
295
+
296
+ if (isUseful) {
297
+ const element: UIElement = {
298
+ text,
299
+ contentDescription,
300
+ type: node['@_class'] || 'unknown',
301
+ resourceId: node['@_resource-id'] || null,
302
+ clickable,
303
+ enabled: node['@_enabled'] === 'true',
304
+ visible: true,
305
+ bounds,
306
+ center: getCenter(bounds),
307
+ depth
308
+ };
309
+
310
+ if (parentIndex !== -1) {
311
+ element.parentId = parentIndex;
312
+ }
313
+
314
+ elements.push(element);
315
+ currentIndex = elements.length - 1;
316
+ }
317
+ }
318
+
319
+ const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
320
+ const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
321
+
322
+ const childrenIndices: number[] = [];
323
+
324
+ if (node.node) {
325
+ if (Array.isArray(node.node)) {
326
+ node.node.forEach((child: any) => {
327
+ const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
328
+ if (childIndex !== -1) childrenIndices.push(childIndex);
329
+ });
330
+ } else {
331
+ const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
332
+ if (childIndex !== -1) childrenIndices.push(childIndex);
333
+ }
334
+ }
335
+
336
+ if (currentIndex !== -1 && childrenIndices.length > 0) {
337
+ elements[currentIndex].children = childrenIndices;
338
+ }
339
+
340
+ return currentIndex;
212
341
  }
213
342
 
343
+ // Log stream management (one stream per session)
344
+
345
+ // (Legacy active stream map removed from utils during refactor; Observe modules manage their own active streams.)
346
+
214
347
  // Robust log line parser supporting multiple logcat formats
215
- export function parseLogLine(line: string) {
216
- // Collapse internal newlines so multiline stack traces are parseable as a single entry
348
+ export function parseLogLine(line: string) { // Collapse internal newlines so multiline stack traces are parseable as a single entry
217
349
  const rawLine = line
218
350
  const normalizedLine = rawLine.replace(/\r?\n/g, ' ')
219
351
  const entry: any = { timestamp: '', level: '', tag: '', message: rawLine, _iso: null, crash: false }
@@ -313,139 +445,5 @@ export function parseLogLine(line: string) {
313
445
  return entry
314
446
  }
315
447
 
316
- export async function startAndroidLogStream(packageName: string, level: 'error' | 'warn' | 'info' | 'debug' = 'error', deviceId?: string, sessionId: string = 'default'): Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
317
- try {
318
- // Determine PID
319
- const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '')
320
- const pid = (pidOutput || '').trim()
321
- if (!pid) {
322
- return { success: false, error: 'app_not_running' }
323
- }
324
-
325
- // Map level to logcat filter
326
- const levelMap: Record<string, string> = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' }
327
- const filter = levelMap[level] || levelMap['error']
328
-
329
- // Prevent multiple streams per session
330
- if (activeLogStreams.has(sessionId)) {
331
- // stop existing
332
- try { activeLogStreams.get(sessionId)!.proc.kill() } catch {}
333
- activeLogStreams.delete(sessionId)
334
- }
335
-
336
- // Start logcat process
337
- const args = ['logcat', `--pid=${pid}`, filter]
338
- const proc = spawn(ADB, args)
339
-
340
- // Prepare output file
341
- const tmpDir = process.env.TMPDIR || '/tmp'
342
- const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
343
- const stream = createWriteStream(file, { flags: 'a' })
344
-
345
- proc.stdout.on('data', (chunk) => {
346
- const text = chunk.toString()
347
- const lines = text.split(/\r?\n/).filter(Boolean)
348
- for (const l of lines) {
349
- const entry = parseLogLine(l)
350
- stream.write(JSON.stringify(entry) + '\n')
351
- }
352
- })
353
-
354
- proc.stderr.on('data', (chunk) => {
355
- // write stderr lines as message with level 'E'
356
- const text = chunk.toString()
357
- const lines = text.split(/\r?\n/).filter(Boolean)
358
- for (const l of lines) {
359
- const entry = { timestamp: '', level: 'E', tag: 'adb', message: l }
360
- stream.write(JSON.stringify(entry) + '\n')
361
- }
362
- })
363
-
364
- proc.on('close', () => {
365
- stream.end()
366
- activeLogStreams.delete(sessionId)
367
- })
368
-
369
- activeLogStreams.set(sessionId, { proc, file })
370
-
371
- return { success: true, stream_started: true }
372
- } catch {
373
- return { success: false, error: 'log_stream_start_failed' }
374
- }
375
- }
376
-
377
- export async function stopAndroidLogStream(sessionId: string = 'default'): Promise<{ success: boolean }> {
378
- const entry = activeLogStreams.get(sessionId)
379
- if (!entry) return { success: true }
380
- try {
381
- entry.proc.kill()
382
- } catch {}
383
- activeLogStreams.delete(sessionId)
384
- return { success: true }
385
- }
386
-
387
- export async function readLogStreamLines(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
388
- const entry = activeLogStreams.get(sessionId)
389
- if (!entry) return { entries: [] }
390
- try {
391
- const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '')
392
- if (!data) return { entries: [], crash_summary: { crash_detected: false } }
393
- const lines = data.split(/\r?\n/).filter(Boolean)
394
-
395
- // Parse NDJSON lines into objects. Prefer fields written by parseLogLine. For backward compatibility, if _iso or crash are missing, enrich minimally here (avoid duplicating full parse logic).
396
- const parsed = lines.map(l => {
397
- try {
398
- const obj: any = JSON.parse(l)
399
- // Ensure _iso: if missing, try to derive using Date()
400
- if (typeof obj._iso === 'undefined') {
401
- let iso: string | null = null
402
- if (obj.timestamp) {
403
- const d = new Date(obj.timestamp)
404
- if (!isNaN(d.getTime())) iso = d.toISOString()
405
- }
406
- obj._iso = iso
407
- }
408
- // Ensure crash flag: if missing, run minimal heuristic
409
- if (typeof obj.crash === 'undefined') {
410
- const msg = (obj.message || '').toString()
411
- const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/)
412
- if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
413
- obj.crash = true
414
- if (exMatch) obj.exception = exMatch[1]
415
- } else {
416
- obj.crash = false
417
- }
418
- }
419
- return obj
420
- } catch {
421
- return { message: l, _iso: null, crash: false }
422
- }
423
- })
424
-
425
- // Filter by since if provided (accept ISO or epoch ms)
426
- let filtered = parsed
427
- if (since) {
428
- let sinceMs: number | null = null
429
- // If numeric string
430
- if (/^\d+$/.test(since)) sinceMs = Number(since)
431
- else {
432
- const sDate = new Date(since)
433
- if (!isNaN(sDate.getTime())) sinceMs = sDate.getTime()
434
- }
435
- if (sinceMs !== null) {
436
- filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs))
437
- }
438
- }
439
-
440
- // Return the last `limit` entries (most recent)
441
- const entries = filtered.slice(-Math.max(0, limit))
442
-
443
- // Crash summary
444
- const crashEntry = entries.find(e => e.crash)
445
- const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false }
448
+ // Legacy readLogStreamLines shim removed. Use AndroidObserve.readLogStream(sessionId, limit, since) instead.
446
449
 
447
- return { entries, crash_summary }
448
- } catch {
449
- return { entries: [], crash_summary: { crash_detected: false } }
450
- }
451
- }
@@ -1,9 +1,7 @@
1
- import { promises as fs } from "fs"
2
1
  import { spawn } from "child_process"
3
- import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, WaitForElementResponse, TapResponse } from "../types.js"
4
- import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js"
2
+ import { WaitForElementResponse, TapResponse } from "../types.js"
3
+ import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js"
5
4
  import { iOSObserve } from "./observe.js"
6
- import path from "path"
7
5
 
8
6
  export class iOSInteract {
9
7
  private observe = new iOSObserve();
@@ -41,12 +39,8 @@ export class iOSInteract {
41
39
  async tap(x: number, y: number, deviceId: string = "booted"): Promise<TapResponse> {
42
40
  const device = await getIOSDeviceMetadata(deviceId)
43
41
 
44
- // Check for idb
45
- const child = spawn(IDB, ['--version']);
46
- const idbExists = await new Promise<boolean>((resolve) => {
47
- child.on('error', () => resolve(false));
48
- child.on('close', (code) => resolve(code === 0));
49
- });
42
+ // Use shared helper to detect idb
43
+ const idbExists = await isIDBInstalled();
50
44
 
51
45
  if (!idbExists) {
52
46
  return {
@@ -66,7 +60,7 @@ export class iOSInteract {
66
60
  }
67
61
 
68
62
  await new Promise<void>((resolve, reject) => {
69
- const proc = spawn(IDB, args);
63
+ const proc = spawn(getIdbCmd(), args);
70
64
  let stderr = '';
71
65
  proc.stderr.on('data', d => stderr += d.toString());
72
66
  proc.on('close', code => {
@@ -81,174 +75,4 @@ export class iOSInteract {
81
75
  return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
82
76
  }
83
77
  }
84
-
85
- async installApp(appPath: string, deviceId: string = "booted"): Promise<import("../types.js").InstallAppResponse> {
86
- const device = await getIOSDeviceMetadata(deviceId)
87
-
88
- // Helper to find .app bundles under a directory
89
- async function findAppBundle(dir: string): Promise<string | undefined> {
90
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
91
- for (const e of entries) {
92
- const full = path.join(dir, e.name)
93
- if (e.isDirectory()) {
94
- if (full.endsWith('.app')) return full
95
- const found = await findAppBundle(full)
96
- if (found) return found
97
- }
98
- }
99
- return undefined
100
- }
101
-
102
- try {
103
- let toInstall = appPath
104
-
105
- const stat = await fs.stat(appPath).catch(() => null)
106
- if (stat && stat.isDirectory()) {
107
- // If directory already contains a .app, use it
108
- const found = await findAppBundle(appPath)
109
- if (found) {
110
- toInstall = found
111
- } else {
112
- // Attempt to locate an Xcode project and build for simulator
113
- const files = await fs.readdir(appPath).catch(() => [])
114
- // Prefer workspace when present (CocoaPods / multi-project setups)
115
- const workspace = files.find(f => f.endsWith('.xcworkspace'))
116
- const proj = files.find(f => f.endsWith('.xcodeproj'))
117
- if (!workspace && !proj) throw new Error('No .app bundle, .xcworkspace or .xcodeproj found in directory')
118
-
119
- let buildArgs: string[]
120
- let scheme: string
121
- if (workspace) {
122
- const workspacePath = path.join(appPath, workspace)
123
- scheme = workspace.replace(/\.xcworkspace$/, '')
124
- buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet']
125
- } else {
126
- const projectPath = path.join(appPath, proj!)
127
- scheme = proj!.replace(/\.xcodeproj$/, '')
128
- buildArgs = ['-project', projectPath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet']
129
- }
130
-
131
- await new Promise<void>((resolve, reject) => {
132
- const proc = spawn('xcodebuild', buildArgs, { cwd: appPath })
133
- let stderr = ''
134
- proc.stderr?.on('data', d => stderr += d.toString())
135
- proc.on('close', code => {
136
- if (code === 0) resolve()
137
- else reject(new Error(stderr || `xcodebuild failed with code ${code}`))
138
- })
139
- proc.on('error', err => reject(err))
140
- })
141
-
142
- const built = await findAppBundle(appPath)
143
- if (!built) throw new Error('Could not locate built .app after xcodebuild')
144
- toInstall = built
145
- }
146
- }
147
-
148
- // Try simulator install first
149
- try {
150
- const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId)
151
- return { device, installed: true, output: res.output }
152
- } catch (e) {
153
- // If simctl fails and idb is available, try idb install for physical devices
154
- try {
155
- const child = spawn(IDB, ['--version'])
156
- const idbExists = await new Promise<boolean>((resolve) => {
157
- child.on('error', () => resolve(false));
158
- child.on('close', (code) => resolve(code === 0));
159
- });
160
- if (idbExists) {
161
- // Use idb to install (works for physical devices and simulators)
162
- await new Promise<void>((resolve, reject) => {
163
- const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
164
- let stderr = '';
165
- proc.stderr.on('data', d => stderr += d.toString());
166
- proc.on('close', code => {
167
- if (code === 0) resolve();
168
- else reject(new Error(stderr || `idb install failed with code ${code}`));
169
- });
170
- proc.on('error', err => reject(err));
171
- });
172
- return { device, installed: true }
173
- }
174
- } catch {
175
- // fallthrough
176
- }
177
-
178
- return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
179
- }
180
- } catch (e) {
181
- return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
182
- }
183
- }
184
-
185
- async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
186
- validateBundleId(bundleId)
187
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
188
- const device = await getIOSDeviceMetadata(deviceId)
189
- // Simulate launch time and appStarted for demonstration
190
- return {
191
- device,
192
- appStarted: !!result.output,
193
- launchTimeMs: 1000,
194
- }
195
- }
196
-
197
- async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
198
- validateBundleId(bundleId)
199
- await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
200
- const device = await getIOSDeviceMetadata(deviceId)
201
- return {
202
- device,
203
- appTerminated: true
204
- }
205
- }
206
-
207
- async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
208
- // terminateApp already validates bundleId
209
- await this.terminateApp(bundleId, deviceId)
210
- const startResult = await this.startApp(bundleId, deviceId)
211
- return {
212
- device: startResult.device,
213
- appRestarted: startResult.appStarted,
214
- launchTimeMs: startResult.launchTimeMs
215
- }
216
- }
217
-
218
- async resetAppData(bundleId: string, deviceId: string = "booted"): Promise<ResetAppDataResponse> {
219
- validateBundleId(bundleId)
220
- await this.terminateApp(bundleId, deviceId)
221
- const device = await getIOSDeviceMetadata(deviceId)
222
-
223
- // Get data container path
224
- const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
225
- const dataPath = containerResult.output.trim()
226
-
227
- if (!dataPath) {
228
- throw new Error(`Could not find data container for ${bundleId}`)
229
- }
230
-
231
- // Clear contents of Library and Documents
232
- try {
233
- const libraryPath = `${dataPath}/Library`
234
- const documentsPath = `${dataPath}/Documents`
235
- const tmpPath = `${dataPath}/tmp`
236
-
237
- await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => {})
238
- await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => {})
239
- await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
240
-
241
- // Re-create empty directories as they are expected by apps
242
- await fs.mkdir(libraryPath, { recursive: true }).catch(() => {})
243
- await fs.mkdir(documentsPath, { recursive: true }).catch(() => {})
244
- await fs.mkdir(tmpPath, { recursive: true }).catch(() => {})
245
-
246
- return {
247
- device,
248
- dataCleared: true
249
- }
250
- } catch (e) {
251
- throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`)
252
- }
253
- }
254
78
  }