mobile-debug-mcp 0.24.6 → 0.24.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -3
- package/README.md +12 -5
- package/dist/interact/index.js +89 -3
- package/dist/manage/android.js +9 -5
- package/dist/manage/index.js +37 -23
- package/dist/manage/ios.js +12 -15
- package/dist/server/common.js +46 -0
- package/dist/server/tool-handlers.js +120 -33
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +17 -5
- package/dist/utils/cli/idb/check-idb.js +1 -1
- package/docs/CHANGELOG.md +18 -10
- package/eslint.config.js +2 -47
- package/package.json +7 -6
- package/src/interact/index.ts +112 -4
- package/src/manage/android.ts +22 -11
- package/src/manage/index.ts +37 -16
- package/src/manage/ios.ts +28 -15
- package/src/server/common.ts +50 -0
- package/src/server/tool-handlers.ts +136 -32
- package/src/server-core.ts +1 -1
- package/src/utils/android/utils.ts +18 -7
- package/src/utils/cli/idb/check-idb.ts +1 -1
- package/test/device/automated/observe/capture_screenshot.android.smoke.ts +1 -1
- package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +1 -1
- package/test/device/automated/observe/get_logs.android.smoke.ts +1 -1
- package/test/device/automated/observe/get_logs.ios.smoke.ts +1 -1
- package/test/device/automated/observe/get_ui_tree.android.smoke.ts +1 -1
- package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +1 -1
- package/test/device/manual/interact/app_lifecycle.manual.ts +3 -3
- package/test/device/manual/observe/capture_screenshot.manual.ts +2 -2
- package/test/device/manual/observe/get_logs.manual.ts +2 -2
- package/test/device/manual/observe/get_ui_tree.manual.ts +2 -2
- package/test/device/manual/observe/logstream.manual.ts +1 -1
- package/test/device/manual/observe/screen_fingerprint.manual.ts +2 -2
- package/test/unit/manage/scoped_env.test.ts +137 -0
- package/test/unit/observe/find_element.test.ts +64 -5
- package/test/unit/server/capture_screenshot.test.ts +17 -0
- package/test/unit/server/common.test.ts +18 -0
- package/test/unit/server/contract.test.ts +3 -0
- package/test/unit/server/get_logs.test.ts +17 -0
- package/test/unit/server/get_network_activity.test.ts +17 -0
- package/test/unit/server/get_ui_tree.test.ts +17 -0
- package/test/unit/server/response_shapes.test.ts +18 -0
- package/test/unit/server/start_log_stream.test.ts +37 -0
- package/.eslintignore +0 -5
- package/.eslintrc.cjs +0 -18
- package/eslint.config.cjs +0 -36
package/src/interact/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ interface UiElement {
|
|
|
36
36
|
parentId?: number | string | null
|
|
37
37
|
_index?: number
|
|
38
38
|
_interactable?: boolean
|
|
39
|
+
_sliderLike?: boolean
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
interface ResolvedUiElementContext {
|
|
@@ -46,9 +47,22 @@ interface ResolvedUiElementContext {
|
|
|
46
47
|
index: number
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
interface UiResolution {
|
|
51
|
+
width?: number
|
|
52
|
+
height?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
|
|
50
56
|
export class ToolsInteract {
|
|
51
57
|
private static readonly _maxResolvedUiElements = 256
|
|
58
|
+
private static readonly _sliderSearchLookahead = 8
|
|
59
|
+
private static readonly _sliderNegativeGapTolerancePx = 32
|
|
60
|
+
private static readonly _sliderPositiveGapLimitPx = 640
|
|
61
|
+
private static readonly _sliderTrackMinLengthPx = 220
|
|
62
|
+
private static readonly _sliderTrackMaxThicknessPx = 180
|
|
63
|
+
private static readonly _sliderTrackLengthRatio = 0.18
|
|
64
|
+
private static readonly _sliderTrackThicknessRatio = 0.08
|
|
65
|
+
private static readonly _sliderLabelWidthRatio = 1.5
|
|
52
66
|
private static _resolvedUiElements = new Map<string, ResolvedUiElementContext>()
|
|
53
67
|
|
|
54
68
|
private static _normalize(s: any): string {
|
|
@@ -240,6 +254,78 @@ export class ToolsInteract {
|
|
|
240
254
|
return best
|
|
241
255
|
}
|
|
242
256
|
|
|
257
|
+
private static _resolveNearbyActionableControl(
|
|
258
|
+
elements: UiElement[],
|
|
259
|
+
chosen: { el: UiElement, idx: number } | null,
|
|
260
|
+
screen?: UiResolution | null
|
|
261
|
+
): { el: UiElement, idx: number, sliderLike?: boolean } | null {
|
|
262
|
+
if (!chosen) return null
|
|
263
|
+
|
|
264
|
+
const labelBounds = ToolsInteract._normalizeBounds(chosen.el.bounds)
|
|
265
|
+
if (!labelBounds) return null
|
|
266
|
+
|
|
267
|
+
const [labelLeft, labelTop, labelRight, labelBottom] = labelBounds
|
|
268
|
+
const labelWidth = labelRight - labelLeft
|
|
269
|
+
const labelHeight = labelBottom - labelTop
|
|
270
|
+
const screenWidth = Number(screen?.width) > 0 ? Number(screen?.width) : 0
|
|
271
|
+
const screenHeight = Number(screen?.height) > 0 ? Number(screen?.height) : 0
|
|
272
|
+
const minTrackLengthPx = Math.max(
|
|
273
|
+
ToolsInteract._sliderTrackMinLengthPx,
|
|
274
|
+
screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackLengthRatio) : 0,
|
|
275
|
+
screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackLengthRatio) : 0
|
|
276
|
+
)
|
|
277
|
+
const maxTrackThicknessPx = Math.max(
|
|
278
|
+
ToolsInteract._sliderTrackMaxThicknessPx,
|
|
279
|
+
screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackThicknessRatio) : 0,
|
|
280
|
+
screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackThicknessRatio) : 0
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
let best: { el: UiElement, idx: number, sliderLike?: boolean } | null = null
|
|
284
|
+
let bestScore = Infinity
|
|
285
|
+
|
|
286
|
+
for (let i = chosen.idx + 1; i < Math.min(elements.length, chosen.idx + ToolsInteract._sliderSearchLookahead); i++) {
|
|
287
|
+
const candidate = elements[i]
|
|
288
|
+
if (!candidate || !(candidate.clickable || candidate.focusable) || candidate.visible === false) continue
|
|
289
|
+
|
|
290
|
+
const candidateBounds = ToolsInteract._normalizeBounds(candidate.bounds)
|
|
291
|
+
if (!candidateBounds) continue
|
|
292
|
+
|
|
293
|
+
const [left, top, right] = candidateBounds
|
|
294
|
+
const width = right - left
|
|
295
|
+
const height = candidateBounds[3] - top
|
|
296
|
+
const verticalGap = top - labelBottom
|
|
297
|
+
if (verticalGap < -ToolsInteract._sliderNegativeGapTolerancePx || verticalGap > ToolsInteract._sliderPositiveGapLimitPx) continue
|
|
298
|
+
|
|
299
|
+
const horizontalOverlap = Math.min(labelRight, right) - Math.max(labelLeft, left)
|
|
300
|
+
if (horizontalOverlap < -ToolsInteract._sliderNegativeGapTolerancePx) continue
|
|
301
|
+
|
|
302
|
+
const candidateText = ToolsInteract._normalize(candidate.text ?? candidate.label ?? candidate.value ?? '')
|
|
303
|
+
const candidateContent = ToolsInteract._normalize(candidate.contentDescription ?? candidate.contentDesc ?? candidate.accessibilityLabel ?? '')
|
|
304
|
+
const candidateClass = ToolsInteract._normalize(candidate.type ?? candidate.class ?? '')
|
|
305
|
+
|
|
306
|
+
let score = verticalGap
|
|
307
|
+
const horizontalTrackLike =
|
|
308
|
+
width >= Math.max(minTrackLengthPx, Math.floor(labelWidth * ToolsInteract._sliderLabelWidthRatio)) &&
|
|
309
|
+
height <= maxTrackThicknessPx
|
|
310
|
+
const verticalTrackLike =
|
|
311
|
+
height >= Math.max(minTrackLengthPx, Math.floor(labelHeight * ToolsInteract._sliderLabelWidthRatio)) &&
|
|
312
|
+
width <= maxTrackThicknessPx
|
|
313
|
+
const trackLike = /slider|seek|range/i.test(candidateClass) || horizontalTrackLike || verticalTrackLike
|
|
314
|
+
if (!candidateText && !candidateContent) score -= 18
|
|
315
|
+
if (trackLike) score -= 30
|
|
316
|
+
if (/view|layout|group|frame/i.test(candidateClass)) score -= 10
|
|
317
|
+
if (width > labelWidth * ToolsInteract._sliderLabelWidthRatio) score -= 8
|
|
318
|
+
if (candidateText || candidateContent) score += 20
|
|
319
|
+
|
|
320
|
+
if (score < bestScore) {
|
|
321
|
+
bestScore = score
|
|
322
|
+
best = { el: candidate, idx: i, sliderLike: trackLike }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return best
|
|
327
|
+
}
|
|
328
|
+
|
|
243
329
|
|
|
244
330
|
private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
|
|
245
331
|
const effectivePlatform = platform || 'android'
|
|
@@ -347,6 +433,7 @@ export class ToolsInteract {
|
|
|
347
433
|
|
|
348
434
|
let best: UiElement | null = null
|
|
349
435
|
let bestScore = 0
|
|
436
|
+
let lastTree: any = null
|
|
350
437
|
|
|
351
438
|
const scoreElement = (el: UiElement | null) => {
|
|
352
439
|
if (!el || !el.visible) return 0
|
|
@@ -380,7 +467,8 @@ export class ToolsInteract {
|
|
|
380
467
|
|
|
381
468
|
while (Date.now() <= deadline) {
|
|
382
469
|
try {
|
|
383
|
-
|
|
470
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId })
|
|
471
|
+
lastTree = tree
|
|
384
472
|
if (tree && Array.isArray((tree as any).elements)) {
|
|
385
473
|
const elements = ((tree as any).elements as UiElement[])
|
|
386
474
|
for (let i = 0; i < elements.length; i++) {
|
|
@@ -407,8 +495,8 @@ export class ToolsInteract {
|
|
|
407
495
|
|
|
408
496
|
// If the best match is not interactable, try to resolve an actionable ancestor.
|
|
409
497
|
try {
|
|
410
|
-
const
|
|
411
|
-
const
|
|
498
|
+
const elements = (lastTree && Array.isArray(lastTree.elements)) ? (lastTree.elements as UiElement[]) : []
|
|
499
|
+
const screen = lastTree?.resolution && typeof lastTree.resolution === 'object' ? lastTree.resolution as UiResolution : null
|
|
412
500
|
let chosen = best as any
|
|
413
501
|
const childBounds = Array.isArray(chosen?.bounds) ? chosen.bounds : null
|
|
414
502
|
|
|
@@ -465,6 +553,16 @@ export class ToolsInteract {
|
|
|
465
553
|
// small score bump to reflect actionability
|
|
466
554
|
bestScore = Math.min(1, bestScore + 0.02)
|
|
467
555
|
}
|
|
556
|
+
|
|
557
|
+
if (best && !(best.clickable || best.focusable)) {
|
|
558
|
+
const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best, idx: best._index ?? elements.indexOf(best) }, screen)
|
|
559
|
+
if (nearbyActionable) {
|
|
560
|
+
best = nearbyActionable.el
|
|
561
|
+
best._index = nearbyActionable.idx
|
|
562
|
+
best._interactable = true
|
|
563
|
+
best._sliderLike = nearbyActionable.sliderLike
|
|
564
|
+
}
|
|
565
|
+
}
|
|
468
566
|
} catch (e) { console.error('Error resolving ancestor:', e) }
|
|
469
567
|
|
|
470
568
|
if (!best) return { found: false, error: 'Element not found' }
|
|
@@ -483,8 +581,18 @@ export class ToolsInteract {
|
|
|
483
581
|
tapCoordinates,
|
|
484
582
|
telemetry: {
|
|
485
583
|
matchedIndex: best?._index ?? null,
|
|
486
|
-
matchedInteractable: !!best?._interactable
|
|
584
|
+
matchedInteractable: !!best?._interactable,
|
|
585
|
+
sliderLike: !!best?._sliderLike
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (best?._sliderLike) {
|
|
589
|
+
const isVertical = !!boundsObj && (boundsObj.bottom - boundsObj.top) > (boundsObj.right - boundsObj.left)
|
|
590
|
+
const interactionHint = {
|
|
591
|
+
kind: 'slider',
|
|
592
|
+
axis: isVertical ? 'vertical' : 'horizontal',
|
|
593
|
+
trackBounds: boundsObj
|
|
487
594
|
}
|
|
595
|
+
;(outEl as any).interactionHint = interactionHint
|
|
488
596
|
}
|
|
489
597
|
const scoreVal = Math.min(1, Number(bestScore.toFixed(3)))
|
|
490
598
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
|
package/src/manage/android.ts
CHANGED
|
@@ -2,22 +2,33 @@ import { promises as fs } from 'fs'
|
|
|
2
2
|
import { spawn } from 'child_process'
|
|
3
3
|
import path from 'path'
|
|
4
4
|
import { existsSync } from 'fs'
|
|
5
|
-
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js'
|
|
5
|
+
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk, prepareGradle } from '../utils/android/utils.js'
|
|
6
6
|
import { execAdbWithDiagnostics } from '../utils/diagnostics.js'
|
|
7
7
|
import { detectJavaHome } from '../utils/java.js'
|
|
8
8
|
import { AndroidObserve } from '../observe/android.js'
|
|
9
9
|
import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
|
|
10
10
|
|
|
11
|
+
type BuildEnv = Record<string, string | undefined>
|
|
12
|
+
|
|
13
|
+
export interface AndroidBuildOptions {
|
|
14
|
+
variant?: string
|
|
15
|
+
env?: BuildEnv
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export class AndroidManage {
|
|
12
19
|
private isTestOnlyInstallFailure(output: string | undefined): boolean {
|
|
13
20
|
return typeof output === 'string' && output.includes('INSTALL_FAILED_TEST_ONLY')
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
async build(projectPath: string,
|
|
17
|
-
|
|
23
|
+
async build(projectPath: string, optionsOrVariant?: string | AndroidBuildOptions): Promise<{ artifactPath: string, output?: string } | { error: string }> {
|
|
24
|
+
const options: AndroidBuildOptions = typeof optionsOrVariant === 'string' ? { variant: optionsOrVariant } : (optionsOrVariant || {})
|
|
18
25
|
try {
|
|
26
|
+
const env = {
|
|
27
|
+
...(options.env || {}),
|
|
28
|
+
...(options.variant ? { MCP_GRADLE_TASK: options.variant } : {})
|
|
29
|
+
}
|
|
19
30
|
// Always use the shared prepareGradle utility for consistent env/setup
|
|
20
|
-
const { execCmd, gradleArgs, spawnOpts } = await
|
|
31
|
+
const { execCmd, gradleArgs, spawnOpts } = await prepareGradle(projectPath, env)
|
|
21
32
|
await new Promise<void>((resolve, reject) => {
|
|
22
33
|
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
23
34
|
let stderr = ''
|
|
@@ -43,13 +54,13 @@ export class AndroidManage {
|
|
|
43
54
|
|
|
44
55
|
let apkToInstall: string = apkPath
|
|
45
56
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
const stat = await fs.stat(apkPath).catch(() => null)
|
|
58
|
+
if (stat && stat.isDirectory()) {
|
|
59
|
+
const detectedJavaHome = await detectJavaHome().catch(() => undefined)
|
|
60
|
+
const env = { ...process.env }
|
|
61
|
+
if (detectedJavaHome) {
|
|
62
|
+
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
63
|
+
env.JAVA_HOME = detectedJavaHome
|
|
53
64
|
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
|
|
54
65
|
console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome)
|
|
55
66
|
}
|
package/src/manage/index.ts
CHANGED
|
@@ -60,25 +60,37 @@ export async function detectProjectPlatform(projectPath: string): Promise<'ios'|
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
type BuildEnv = Record<string, string | undefined>
|
|
64
|
+
|
|
65
|
+
function mergeDefinedEnv(...parts: Array<BuildEnv | undefined>): BuildEnv {
|
|
66
|
+
const merged: BuildEnv = {}
|
|
67
|
+
for (const part of parts) {
|
|
68
|
+
if (!part) continue
|
|
69
|
+
for (const [key, value] of Object.entries(part)) {
|
|
70
|
+
if (typeof value === 'undefined') continue
|
|
71
|
+
merged[key] = value
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return merged
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
export class ToolsManage {
|
|
64
78
|
static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }: { projectPath: string, gradleTask?: string, maxWorkers?: number, gradleCache?: boolean, forceClean?: boolean }) {
|
|
65
79
|
const android = new AndroidManage()
|
|
66
|
-
// prepare gradle options via environment hints
|
|
67
|
-
if (typeof maxWorkers === 'number') process.env.MCP_GRADLE_WORKERS = String(maxWorkers)
|
|
68
|
-
if (typeof gradleCache === 'boolean') process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0'
|
|
69
|
-
if (forceClean) process.env.MCP_FORCE_CLEAN_ANDROID = '1'
|
|
70
80
|
const task = gradleTask || 'assembleDebug'
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
return await (android as any).build(projectPath, {
|
|
82
|
+
variant: task,
|
|
83
|
+
env: mergeDefinedEnv({
|
|
84
|
+
MCP_GRADLE_TASK: task,
|
|
85
|
+
MCP_GRADLE_WORKERS: typeof maxWorkers === 'number' ? String(maxWorkers) : undefined,
|
|
86
|
+
MCP_GRADLE_CACHE: typeof gradleCache === 'boolean' ? (gradleCache ? '1' : '0') : undefined,
|
|
87
|
+
MCP_FORCE_CLEAN_ANDROID: forceClean ? '1' : undefined
|
|
88
|
+
})
|
|
89
|
+
})
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }: { projectPath: string, workspace?: string, project?: string, scheme?: string, destinationUDID?: string, derivedDataPath?: string, buildJobs?: number, forceClean?: boolean }) {
|
|
76
93
|
const ios = new iOSManage()
|
|
77
|
-
// Use provided options rather than env-only; still set env fallbacks for downstream tools
|
|
78
|
-
if (derivedDataPath) process.env.MCP_DERIVED_DATA = derivedDataPath
|
|
79
|
-
if (typeof buildJobs === 'number') process.env.MCP_BUILD_JOBS = String(buildJobs)
|
|
80
|
-
if (forceClean) process.env.MCP_FORCE_CLEAN_IOS = '1'
|
|
81
|
-
if (destinationUDID) process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID
|
|
82
94
|
|
|
83
95
|
const opts: any = {}
|
|
84
96
|
if (_workspace) opts.workspace = _workspace
|
|
@@ -86,12 +98,21 @@ export class ToolsManage {
|
|
|
86
98
|
if (_scheme) opts.scheme = _scheme
|
|
87
99
|
if (destinationUDID) opts.destinationUDID = destinationUDID
|
|
88
100
|
if (derivedDataPath) opts.derivedDataPath = derivedDataPath
|
|
89
|
-
if (
|
|
101
|
+
if (typeof buildJobs === 'number') opts.buildJobs = buildJobs
|
|
102
|
+
if (typeof forceClean === 'boolean') opts.forceClean = forceClean
|
|
90
103
|
// prefer explicit xcodebuild path from env
|
|
91
104
|
if (process.env.XCODEBUILD_PATH) opts.xcodeCmd = process.env.XCODEBUILD_PATH
|
|
92
105
|
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
return await (ios as any).build(projectPath, {
|
|
107
|
+
...opts,
|
|
108
|
+
env: mergeDefinedEnv({
|
|
109
|
+
MCP_DERIVED_DATA: derivedDataPath,
|
|
110
|
+
MCP_XCODE_JOBS: typeof buildJobs === 'number' ? String(buildJobs) : undefined,
|
|
111
|
+
MCP_FORCE_CLEAN: typeof forceClean === 'boolean' ? (forceClean ? '1' : '0') : undefined,
|
|
112
|
+
MCP_XCODE_DESTINATION_UDID: destinationUDID,
|
|
113
|
+
XCODEBUILD_PATH: process.env.XCODEBUILD_PATH
|
|
114
|
+
})
|
|
115
|
+
})
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }: { projectPath: string, platform?: 'android'|'ios', buildMode?: 'debug'|'release'|'profile', maxWorkers?: number, forceClean?: boolean }) {
|
|
@@ -178,11 +199,11 @@ export class ToolsManage {
|
|
|
178
199
|
const chosen = platform || 'android'
|
|
179
200
|
if (chosen === 'android') {
|
|
180
201
|
const android = new AndroidManage()
|
|
181
|
-
const artifact = await (android as any).build(projectPath, variant)
|
|
202
|
+
const artifact = await (android as any).build(projectPath, { variant })
|
|
182
203
|
return artifact
|
|
183
204
|
} else {
|
|
184
205
|
const ios = new iOSManage()
|
|
185
|
-
const artifact = await (ios as any).build(projectPath, variant)
|
|
206
|
+
const artifact = await (ios as any).build(projectPath, { variant })
|
|
186
207
|
return artifact
|
|
187
208
|
}
|
|
188
209
|
}
|
package/src/manage/ios.ts
CHANGED
|
@@ -5,12 +5,25 @@ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validate
|
|
|
5
5
|
import { iOSObserve } from "../observe/ios.js"
|
|
6
6
|
import path from "path"
|
|
7
7
|
|
|
8
|
+
type BuildEnv = Record<string, string | undefined>
|
|
9
|
+
|
|
10
|
+
export interface IOSBuildOptions {
|
|
11
|
+
workspace?: string
|
|
12
|
+
project?: string
|
|
13
|
+
scheme?: string
|
|
14
|
+
destinationUDID?: string
|
|
15
|
+
derivedDataPath?: string
|
|
16
|
+
buildJobs?: number
|
|
17
|
+
forceClean?: boolean
|
|
18
|
+
xcodeCmd?: string
|
|
19
|
+
env?: BuildEnv
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export class iOSManage {
|
|
9
|
-
async build(projectPath: string, optsOrVariant?: string |
|
|
23
|
+
async build(projectPath: string, optsOrVariant?: string | IOSBuildOptions): Promise<{ artifactPath: string, output?: string } | { error: string, diagnostics?: any }> {
|
|
10
24
|
// Support legacy variant string as second arg
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
else opts = optsOrVariant || {}
|
|
25
|
+
const opts: IOSBuildOptions = typeof optsOrVariant === 'string' ? {} : (optsOrVariant || {})
|
|
26
|
+
const env = { ...process.env, ...(opts.env || {}) }
|
|
14
27
|
|
|
15
28
|
try {
|
|
16
29
|
// Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
|
|
@@ -65,7 +78,7 @@ export class iOSManage {
|
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
// Determine destination: prefer explicit option, then env var, otherwise use booted simulator UDID
|
|
68
|
-
let destinationUDID = opts.destinationUDID ||
|
|
81
|
+
let destinationUDID = opts.destinationUDID || env.MCP_XCODE_DESTINATION_UDID || env.MCP_XCODE_DESTINATION || ''
|
|
69
82
|
if (!destinationUDID) {
|
|
70
83
|
try {
|
|
71
84
|
const meta = await getIOSDeviceMetadata('booted')
|
|
@@ -74,7 +87,7 @@ export class iOSManage {
|
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
// Determine xcode command early so it can be used when detecting schemes
|
|
77
|
-
const xcodeCmd = opts.xcodeCmd ||
|
|
90
|
+
const xcodeCmd = opts.xcodeCmd || env.XCODEBUILD_PATH || 'xcodebuild'
|
|
78
91
|
|
|
79
92
|
// Determine available schemes by querying xcodebuild -list rather than guessing
|
|
80
93
|
async function detectScheme(xcodeCmdInner: string, workspacePath?: string, projectPathFull?: string, cwd?: string): Promise<string | null> {
|
|
@@ -98,11 +111,11 @@ export class iOSManage {
|
|
|
98
111
|
let chosenScheme: string | null = opts.scheme || null
|
|
99
112
|
|
|
100
113
|
// Derived data and result bundle (agent-configurable)
|
|
101
|
-
const derivedDataPath = opts.derivedDataPath ||
|
|
114
|
+
const derivedDataPath = opts.derivedDataPath || env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData')
|
|
102
115
|
// Use unique result bundle path by default to avoid collisions
|
|
103
|
-
const resultBundlePath =
|
|
104
|
-
const xcodeJobs = parseInt(
|
|
105
|
-
const forceClean = opts.forceClean
|
|
116
|
+
const resultBundlePath = env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`)
|
|
117
|
+
const xcodeJobs = typeof opts.buildJobs === 'number' ? opts.buildJobs : (parseInt(env.MCP_XCODE_JOBS || '', 10) || 4)
|
|
118
|
+
const forceClean = typeof opts.forceClean === 'boolean' ? opts.forceClean : env.MCP_FORCE_CLEAN === '1'
|
|
106
119
|
|
|
107
120
|
// ensure result dirs exist
|
|
108
121
|
await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => {})
|
|
@@ -147,8 +160,8 @@ export class iOSManage {
|
|
|
147
160
|
await fs.mkdir(resultsDir, { recursive: true }).catch(() => {})
|
|
148
161
|
|
|
149
162
|
|
|
150
|
-
const XCODEBUILD_TIMEOUT = parseInt(
|
|
151
|
-
const MAX_RETRIES = parseInt(
|
|
163
|
+
const XCODEBUILD_TIMEOUT = parseInt(env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000 // default 3 minutes
|
|
164
|
+
const MAX_RETRIES = parseInt(env.MCP_XCODEBUILD_RETRIES || '', 10) || 1
|
|
152
165
|
|
|
153
166
|
const tries = MAX_RETRIES + 1
|
|
154
167
|
let lastStdout = ''
|
|
@@ -157,8 +170,8 @@ export class iOSManage {
|
|
|
157
170
|
|
|
158
171
|
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
159
172
|
// Run xcodebuild with a watchdog
|
|
160
|
-
|
|
161
|
-
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir })
|
|
173
|
+
const res = await new Promise<{ code: number | null, stdout: string, stderr: string, killedByWatchdog?: boolean }>((resolve) => {
|
|
174
|
+
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir, env })
|
|
162
175
|
let stdout = ''
|
|
163
176
|
let stderr = ''
|
|
164
177
|
|
|
@@ -215,7 +228,7 @@ export class iOSManage {
|
|
|
215
228
|
if (lastErr) {
|
|
216
229
|
// Include diagnostics and result bundle path when available; provide structured info useful for agents
|
|
217
230
|
const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`
|
|
218
|
-
|
|
231
|
+
const envSnapshot = { PATH: env.PATH }
|
|
219
232
|
return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}`, diagnostics: { exitCode: (lastErr as any).code || null, invokedCommand, cwd: projectRootDir, envSnapshot } }
|
|
220
233
|
}
|
|
221
234
|
|
package/src/server/common.ts
CHANGED
|
@@ -20,6 +20,56 @@ export type ToolCallResult = Awaited<ReturnType<typeof wrapResponse>> | {
|
|
|
20
20
|
}
|
|
21
21
|
export type ToolHandler = (args: ToolCallArgs) => Promise<ToolCallResult>
|
|
22
22
|
|
|
23
|
+
export function getStringArg(args: ToolCallArgs, key: string): string | undefined {
|
|
24
|
+
const value = args[key]
|
|
25
|
+
return typeof value === 'string' ? value : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requireStringArg(args: ToolCallArgs, key: string): string {
|
|
29
|
+
const value = getStringArg(args, key)
|
|
30
|
+
if (value === undefined) throw new Error(`Missing or invalid string argument: ${key}`)
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getNumberArg(args: ToolCallArgs, key: string): number | undefined {
|
|
35
|
+
const value = args[key]
|
|
36
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function requireNumberArg(args: ToolCallArgs, key: string): number {
|
|
40
|
+
const value = getNumberArg(args, key)
|
|
41
|
+
if (value === undefined) throw new Error(`Missing or invalid number argument: ${key}`)
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getBooleanArg(args: ToolCallArgs, key: string): boolean | undefined {
|
|
46
|
+
const value = args[key]
|
|
47
|
+
return typeof value === 'boolean' ? value : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function requireBooleanArg(args: ToolCallArgs, key: string): boolean {
|
|
51
|
+
const value = getBooleanArg(args, key)
|
|
52
|
+
if (value === undefined) throw new Error(`Missing or invalid boolean argument: ${key}`)
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getObjectArg<T extends Record<string, unknown>>(args: ToolCallArgs, key: string): T | undefined {
|
|
57
|
+
const value = args[key]
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
59
|
+
return value as T
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function requireObjectArg<T extends Record<string, unknown>>(args: ToolCallArgs, key: string): T {
|
|
63
|
+
const value = getObjectArg<T>(args, key)
|
|
64
|
+
if (value === undefined) throw new Error(`Missing or invalid object argument: ${key}`)
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getArrayArg<T>(args: ToolCallArgs, key: string): T[] | undefined {
|
|
69
|
+
const value = args[key]
|
|
70
|
+
return Array.isArray(value) ? value as T[] : undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
23
73
|
let actionSequence = 0
|
|
24
74
|
|
|
25
75
|
export function nextActionId(actionType: string, timestamp: number) {
|