mobile-debug-mcp 0.14.0 → 0.16.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/dist/android/interact.js +2 -2
- package/dist/android/observe.js +13 -0
- package/dist/cli/ios/run-ios-smoke.js +2 -2
- package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
- package/dist/interact/android.js +91 -0
- package/dist/interact/index.js +76 -0
- package/dist/interact/ios.js +120 -0
- package/dist/interact/shared/fingerprint.js +1 -0
- package/dist/interact/shared/scroll_to_element.js +1 -0
- package/dist/ios/interact.js +2 -2
- package/dist/ios/observe.js +12 -0
- package/dist/manage/android.js +162 -0
- package/dist/manage/index.js +364 -0
- package/dist/manage/ios.js +353 -0
- package/dist/observe/android.js +351 -0
- package/dist/observe/fingerprint.js +1 -0
- package/dist/observe/index.js +85 -0
- package/dist/observe/ios.js +320 -0
- package/dist/observe/test/device/logstream-real.js +34 -0
- package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
- package/dist/observe/test/device/run-scroll-test-android.js +22 -0
- package/dist/observe/test/device/test-ui-tree.js +67 -0
- package/dist/observe/test/device/wait_for_element_real.js +69 -0
- package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
- package/dist/observe/test/unit/logparse.test.js +39 -0
- package/dist/observe/test/unit/logstream.test.js +41 -0
- package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
- package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
- package/dist/server.js +41 -5
- package/dist/shared/fingerprint.js +72 -0
- package/dist/shared/scroll_to_element.js +98 -0
- package/dist/tools/interact.js +2 -2
- package/dist/tools/manage.js +2 -2
- package/dist/tools/observe.js +45 -43
- package/dist/utils/android/utils.js +373 -0
- package/dist/utils/cli/idb/check-idb.js +84 -0
- package/dist/utils/cli/idb/idb-helper.js +91 -0
- package/dist/utils/cli/idb/install-idb.js +82 -0
- package/dist/utils/cli/ios/preflight-ios.js +155 -0
- package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
- package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
- package/dist/utils/diagnostics.js +1 -1
- package/dist/utils/exec.js +34 -0
- package/dist/utils/ios/utils.js +301 -0
- package/dist/utils/resolve-device.js +2 -2
- package/dist/utils/ui/index.js +169 -0
- package/docs/CHANGELOG.md +8 -0
- package/docs/tools/interact.md +29 -0
- package/docs/tools/observe.md +24 -0
- package/package.json +1 -1
- package/src/{android/interact.ts → interact/android.ts} +3 -3
- package/src/{tools/interact.ts → interact/index.ts} +47 -3
- package/src/{ios/interact.ts → interact/ios.ts} +3 -3
- package/src/{android/manage.ts → manage/android.ts} +2 -2
- package/src/{tools/manage.ts → manage/index.ts} +7 -4
- package/src/{ios/manage.ts → manage/ios.ts} +1 -1
- package/src/{android/observe.ts → observe/android.ts} +14 -26
- package/src/observe/index.ts +92 -0
- package/src/{ios/observe.ts → observe/ios.ts} +17 -35
- package/src/server.ts +45 -6
- package/src/types.ts +1 -0
- package/src/{android → utils/android}/utils.ts +12 -79
- package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
- package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
- package/src/utils/diagnostics.ts +1 -1
- package/src/utils/exec.ts +33 -0
- package/src/{ios → utils/ios}/utils.ts +2 -2
- package/src/utils/resolve-device.ts +2 -2
- package/src/{tools/scroll_to_element.ts → utils/ui/index.ts} +73 -2
- package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
- package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
- package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
- package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
- package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
- package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
- package/test/observe/device/run-screen-fingerprint.ts +36 -0
- package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
- package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
- package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
- package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
- package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
- package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
- package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
- package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
- package/test/unit/index.ts +13 -11
- package/src/tools/observe.ts +0 -82
- package/test/device/README.md +0 -49
- package/test/device/index.ts +0 -27
- package/test/device/utils/test-dist.ts +0 -41
- package/test/unit/utils/detect-java.test.ts +0 -22
- /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
- /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
- /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
- /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
- /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
- /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
- /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { promises as fs } from "fs"
|
|
3
3
|
import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo } from "../types.js"
|
|
4
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "
|
|
4
|
+
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
5
5
|
import { createWriteStream, promises as fsPromises } from 'fs'
|
|
6
6
|
import path from 'path'
|
|
7
|
-
import { parseLogLine } from '../android/utils.js'
|
|
8
|
-
|
|
9
|
-
// --- Helper Functions Specific to Observe ---
|
|
7
|
+
import { parseLogLine } from '../utils/android/utils.js'
|
|
8
|
+
import { computeScreenFingerprint } from '../utils/ui/index.js'
|
|
10
9
|
|
|
11
10
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
12
11
|
|
|
@@ -25,7 +24,6 @@ interface IDBElement {
|
|
|
25
24
|
|
|
26
25
|
function parseIDBFrame(frame: any): [number, number, number, number] {
|
|
27
26
|
if (!frame) return [0, 0, 0, 0];
|
|
28
|
-
// Handle string frames like "{{0, 0}, {402, 874}}"
|
|
29
27
|
if (typeof frame === 'string') {
|
|
30
28
|
const nums = frame.match(/-?\d+(?:\.\d+)?/g);
|
|
31
29
|
if (!nums || nums.length < 4) return [0, 0, 0, 0];
|
|
@@ -53,29 +51,25 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
|
|
|
53
51
|
|
|
54
52
|
let currentIndex = -1;
|
|
55
53
|
|
|
56
|
-
// Prefer standard keys, fallback to alternatives
|
|
57
54
|
const type = node.AXElementType || node.type || "unknown";
|
|
58
55
|
const label = node.AXLabel || node.label || null;
|
|
59
56
|
const value = node.AXValue || null;
|
|
60
57
|
const frame = node.AXFrame || node.frame;
|
|
61
58
|
const traits = node.AXTraits || [];
|
|
62
59
|
|
|
63
|
-
const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
|
|
60
|
+
const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
|
|
64
61
|
|
|
65
|
-
// Filtering Logic:
|
|
66
|
-
// Keep if clickable OR has visible text/label OR has value
|
|
67
|
-
// Also keep 'Window' or 'Application' types as they define the root structure often, though usually depth 0
|
|
68
62
|
const isUseful = clickable || (label && label.length > 0) || (value && value.length > 0) || type === "Application" || type === "Window";
|
|
69
63
|
|
|
70
64
|
if (isUseful) {
|
|
71
65
|
const bounds = parseIDBFrame(frame);
|
|
72
66
|
const element: UIElement = {
|
|
73
67
|
text: label,
|
|
74
|
-
contentDescription: value,
|
|
68
|
+
contentDescription: value,
|
|
75
69
|
type: type,
|
|
76
70
|
resourceId: node.AXUniqueId || null,
|
|
77
71
|
clickable: clickable,
|
|
78
|
-
enabled: true,
|
|
72
|
+
enabled: true,
|
|
79
73
|
visible: true,
|
|
80
74
|
bounds: bounds,
|
|
81
75
|
center: getCenter(bounds),
|
|
@@ -90,8 +84,6 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
|
|
|
90
84
|
currentIndex = elements.length - 1;
|
|
91
85
|
}
|
|
92
86
|
|
|
93
|
-
// If current node was skipped, children inherit parentIndex and depth (flattening)
|
|
94
|
-
// If current node was added, children use currentIndex and depth + 1
|
|
95
87
|
const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
|
|
96
88
|
const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
|
|
97
89
|
|
|
@@ -113,12 +105,8 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
|
|
|
113
105
|
return currentIndex;
|
|
114
106
|
}
|
|
115
107
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// iOS live log stream support (moved from ios/utils to observe)
|
|
119
108
|
const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
|
|
120
109
|
|
|
121
|
-
// Test helpers
|
|
122
110
|
export function _setIOSActiveLogStream(sessionId: string, file: string) {
|
|
123
111
|
iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
|
|
124
112
|
}
|
|
@@ -133,9 +121,6 @@ export class iOSObserve {
|
|
|
133
121
|
}
|
|
134
122
|
|
|
135
123
|
async getLogs(appId?: string, deviceId: string = "booted"): Promise<GetLogsResponse> {
|
|
136
|
-
// If appId is provided, use predicate filtering
|
|
137
|
-
// Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
|
|
138
|
-
// but we do need to construct the predicate correctly for log show.
|
|
139
124
|
const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
|
|
140
125
|
if (appId) {
|
|
141
126
|
validateBundleId(appId)
|
|
@@ -157,25 +142,19 @@ export class iOSObserve {
|
|
|
157
142
|
const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
|
|
158
143
|
|
|
159
144
|
try {
|
|
160
|
-
// 1. Capture screenshot to temp file
|
|
161
145
|
await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
|
|
162
146
|
|
|
163
|
-
// 2. Read file as base64
|
|
164
147
|
const buffer = await fs.readFile(tmpFile)
|
|
165
148
|
const base64 = buffer.toString('base64')
|
|
166
149
|
|
|
167
|
-
// 3. Clean up
|
|
168
150
|
await fs.rm(tmpFile).catch(() => {})
|
|
169
151
|
|
|
170
152
|
return {
|
|
171
153
|
device,
|
|
172
154
|
screenshot: base64,
|
|
173
|
-
// Default resolution since we can't easily parse it without extra libs
|
|
174
|
-
// Clients will read the real dimensions from the PNG header anyway
|
|
175
155
|
resolution: { width: 0, height: 0 },
|
|
176
156
|
}
|
|
177
157
|
} catch (e) {
|
|
178
|
-
// Ensure cleanup happens even on error
|
|
179
158
|
await fs.rm(tmpFile).catch(() => {})
|
|
180
159
|
throw new Error(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`)
|
|
181
160
|
}
|
|
@@ -184,7 +163,6 @@ export class iOSObserve {
|
|
|
184
163
|
async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
|
|
185
164
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
186
165
|
|
|
187
|
-
// idb is required
|
|
188
166
|
const idbExists = await isIDBInstalled();
|
|
189
167
|
if (!idbExists) {
|
|
190
168
|
return {
|
|
@@ -196,10 +174,8 @@ export class iOSObserve {
|
|
|
196
174
|
};
|
|
197
175
|
}
|
|
198
176
|
|
|
199
|
-
// Resolve UDID if needed
|
|
200
177
|
const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
|
|
201
178
|
|
|
202
|
-
// Retry Logic
|
|
203
179
|
let jsonContent: any = null;
|
|
204
180
|
let attempts = 0;
|
|
205
181
|
const maxAttempts = 3;
|
|
@@ -207,7 +183,6 @@ export class iOSObserve {
|
|
|
207
183
|
while (attempts < maxAttempts) {
|
|
208
184
|
attempts++;
|
|
209
185
|
try {
|
|
210
|
-
// Stabilization delay
|
|
211
186
|
await delay(300 + (attempts * 100));
|
|
212
187
|
|
|
213
188
|
const args = ['ui', 'describe-all', '--json'];
|
|
@@ -255,7 +230,6 @@ export class iOSObserve {
|
|
|
255
230
|
|
|
256
231
|
try {
|
|
257
232
|
const elements: UIElement[] = [];
|
|
258
|
-
// idb describe-all returns either a root object or an array of root nodes
|
|
259
233
|
if (Array.isArray(jsonContent)) {
|
|
260
234
|
for (const node of jsonContent) {
|
|
261
235
|
traverseIDBNode(node, elements);
|
|
@@ -264,7 +238,6 @@ export class iOSObserve {
|
|
|
264
238
|
traverseIDBNode(jsonContent, elements);
|
|
265
239
|
}
|
|
266
240
|
|
|
267
|
-
// Infer resolution from root element if possible (usually the Window/Application frame)
|
|
268
241
|
let width = 0;
|
|
269
242
|
let height = 0;
|
|
270
243
|
if (elements.length > 0) {
|
|
@@ -290,7 +263,17 @@ export class iOSObserve {
|
|
|
290
263
|
}
|
|
291
264
|
}
|
|
292
265
|
|
|
293
|
-
|
|
266
|
+
async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
|
|
267
|
+
try {
|
|
268
|
+
const tree = await this.getUITree(deviceId)
|
|
269
|
+
if (!tree || tree.error) return { fingerprint: null, error: tree && (tree as any).error }
|
|
270
|
+
|
|
271
|
+
return computeScreenFingerprint(tree, null, 'ios', 50)
|
|
272
|
+
} catch (e) {
|
|
273
|
+
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
294
277
|
async startLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
|
|
295
278
|
try {
|
|
296
279
|
const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`
|
|
@@ -303,7 +286,6 @@ export class iOSObserve {
|
|
|
303
286
|
const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate]
|
|
304
287
|
const proc = spawn(getXcrunCmd(), args)
|
|
305
288
|
|
|
306
|
-
// Prepare output file
|
|
307
289
|
const tmpDir = process.env.TMPDIR || '/tmp'
|
|
308
290
|
const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`)
|
|
309
291
|
const stream = createWriteStream(file, { flags: 'a' })
|
package/src/server.ts
CHANGED
|
@@ -14,11 +14,11 @@ import {
|
|
|
14
14
|
InstallAppResponse
|
|
15
15
|
} from "./types.js"
|
|
16
16
|
|
|
17
|
-
import { ToolsManage } from './
|
|
18
|
-
import { ToolsInteract } from './
|
|
19
|
-
import { ToolsObserve } from './
|
|
20
|
-
import { AndroidManage } from './
|
|
21
|
-
import { iOSManage } from './
|
|
17
|
+
import { ToolsManage } from './manage/index.js'
|
|
18
|
+
import { ToolsInteract } from './interact/index.js'
|
|
19
|
+
import { ToolsObserve } from './observe/index.js'
|
|
20
|
+
import { AndroidManage } from './manage/index.js'
|
|
21
|
+
import { iOSManage } from './manage/index.js'
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
const server = new Server(
|
|
@@ -283,6 +283,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
285
|
},
|
|
286
|
+
{
|
|
287
|
+
name: "get_screen_fingerprint",
|
|
288
|
+
description: "Generate a stable fingerprint representing the current visible screen (activity + visible UI elements).",
|
|
289
|
+
inputSchema: {
|
|
290
|
+
type: "object",
|
|
291
|
+
properties: {
|
|
292
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
|
|
293
|
+
deviceId: { type: "string", description: "Optional device id/udid to target" }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "wait_for_screen_change",
|
|
299
|
+
description: "Wait until the current screen fingerprint differs from a provided previousFingerprint. Useful to wait for navigation/animation completion.",
|
|
300
|
+
inputSchema: {
|
|
301
|
+
type: "object",
|
|
302
|
+
properties: {
|
|
303
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
|
|
304
|
+
previousFingerprint: { type: "string", description: "The fingerprint to compare against (required)" },
|
|
305
|
+
timeoutMs: { type: "number", description: "Timeout in ms to wait for change (default 5000)", default: 5000 },
|
|
306
|
+
pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300)", default: 300 },
|
|
307
|
+
deviceId: { type: "string", description: "Optional device id/udid to target" }
|
|
308
|
+
},
|
|
309
|
+
required: ["previousFingerprint"]
|
|
310
|
+
}
|
|
311
|
+
},
|
|
286
312
|
{
|
|
287
313
|
name: "wait_for_element",
|
|
288
314
|
description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
|
|
@@ -311,6 +337,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
311
337
|
required: ["platform", "text"]
|
|
312
338
|
}
|
|
313
339
|
},
|
|
340
|
+
|
|
314
341
|
{
|
|
315
342
|
name: "tap",
|
|
316
343
|
description: "Simulate a finger tap on the device screen at specific coordinates.",
|
|
@@ -579,6 +606,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
579
606
|
return wrapResponse(res)
|
|
580
607
|
}
|
|
581
608
|
|
|
609
|
+
if (name === "get_screen_fingerprint") {
|
|
610
|
+
const { platform, deviceId } = (args || {}) as any
|
|
611
|
+
const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId })
|
|
612
|
+
return wrapResponse(res)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (name === "wait_for_screen_change") {
|
|
616
|
+
const { platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId } = (args || {}) as any
|
|
617
|
+
const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId })
|
|
618
|
+
return wrapResponse(res)
|
|
619
|
+
}
|
|
620
|
+
|
|
582
621
|
if (name === "wait_for_element") {
|
|
583
622
|
const { platform, text, timeout, deviceId } = (args || {}) as any
|
|
584
623
|
const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId })
|
|
@@ -644,7 +683,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
644
683
|
const transport = new StdioServerTransport()
|
|
645
684
|
|
|
646
685
|
async function main() {
|
|
647
|
-
await server.connect(transport)
|
|
686
|
+
await (server as any).connect(transport)
|
|
648
687
|
}
|
|
649
688
|
|
|
650
689
|
main().catch((error) => {
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DeviceInfo, UIElement } from "../types.js"
|
|
1
|
+
import { DeviceInfo, UIElement } from "../../types.js"
|
|
3
2
|
import { promises as fsPromises, existsSync } from 'fs'
|
|
4
3
|
import path from 'path'
|
|
5
|
-
import { detectJavaHome } from '../
|
|
4
|
+
import { detectJavaHome } from '../java.js'
|
|
5
|
+
import { execCmd } from '../exec.js'
|
|
6
6
|
|
|
7
7
|
export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
|
|
8
8
|
|
|
@@ -83,87 +83,20 @@ import type { SpawnOptions } from 'child_process'
|
|
|
83
83
|
|
|
84
84
|
export type SpawnOptionsWithTimeout = SpawnOptions & { timeout?: number }
|
|
85
85
|
|
|
86
|
-
export function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
|
|
86
|
+
export async function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
|
|
87
87
|
const adbArgs = getAdbArgs(args, deviceId)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
|
|
93
|
-
const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
|
|
94
|
-
|
|
95
|
-
let stdout = ''
|
|
96
|
-
let stderr = ''
|
|
97
|
-
|
|
98
|
-
if (child.stdout) {
|
|
99
|
-
child.stdout.on('data', (data) => {
|
|
100
|
-
stdout += data.toString()
|
|
101
|
-
})
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (child.stderr) {
|
|
105
|
-
child.stderr.on('data', (data) => {
|
|
106
|
-
stderr += data.toString()
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const timeoutMs = getAdbTimeout(args, customTimeout)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const timeout = setTimeout(() => {
|
|
114
|
-
child.kill()
|
|
115
|
-
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
|
|
116
|
-
}, timeoutMs)
|
|
117
|
-
|
|
118
|
-
child.on('close', (code) => {
|
|
119
|
-
clearTimeout(timeout)
|
|
120
|
-
if (code !== 0) {
|
|
121
|
-
// If there's an actual error (non-zero exit code), reject
|
|
122
|
-
reject(new Error(stderr.trim() || `Command failed with code ${code}`))
|
|
123
|
-
} else {
|
|
124
|
-
// If exit code is 0, resolve with stdout
|
|
125
|
-
resolve(stdout.trim())
|
|
126
|
-
}
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
child.on('error', (err) => {
|
|
130
|
-
clearTimeout(timeout)
|
|
131
|
-
reject(err)
|
|
132
|
-
})
|
|
133
|
-
})
|
|
88
|
+
const timeoutMs = getAdbTimeout(args, options.timeout)
|
|
89
|
+
const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
|
|
90
|
+
if (res.exitCode !== 0) throw new Error(res.stderr || `Command failed with code ${res.exitCode}`)
|
|
91
|
+
return res.stdout
|
|
134
92
|
}
|
|
135
93
|
|
|
136
94
|
// Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
|
|
137
|
-
export function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
|
|
95
|
+
export async function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
|
|
138
96
|
const adbArgs = getAdbArgs(args, deviceId)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let stdout = ''
|
|
144
|
-
let stderr = ''
|
|
145
|
-
|
|
146
|
-
if (child.stdout) child.stdout.on('data', d => { stdout += d.toString() })
|
|
147
|
-
if (child.stderr) child.stderr.on('data', d => { stderr += d.toString() })
|
|
148
|
-
|
|
149
|
-
const timeoutMs = getAdbTimeout(args, customTimeout)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const timeout = setTimeout(() => {
|
|
153
|
-
try { child.kill() } catch {}
|
|
154
|
-
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
|
|
155
|
-
}, timeoutMs)
|
|
156
|
-
|
|
157
|
-
child.on('close', (code) => {
|
|
158
|
-
clearTimeout(timeout)
|
|
159
|
-
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code })
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
child.on('error', (err) => {
|
|
163
|
-
clearTimeout(timeout)
|
|
164
|
-
reject(err)
|
|
165
|
-
})
|
|
166
|
-
})
|
|
97
|
+
const timeoutMs = getAdbTimeout(args, options.timeout)
|
|
98
|
+
const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
|
|
99
|
+
return { stdout: res.stdout, stderr: res.stderr, code: res.exitCode }
|
|
167
100
|
}
|
|
168
101
|
|
|
169
102
|
export function getDeviceInfo(deviceId: string, metadata: Partial<DeviceInfo> = {}): DeviceInfo {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { iOSObserve } from '
|
|
2
|
-
import { iOSManage } from '
|
|
1
|
+
import { iOSObserve } from '../../../observe/index.js';
|
|
2
|
+
import { iOSManage } from '../../../manage/index.js';
|
|
3
3
|
|
|
4
4
|
async function main() {
|
|
5
5
|
const appId = process.argv[2] || 'com.apple.springboard';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { iOSObserve } from '
|
|
2
|
-
import { iOSInteract } from '
|
|
1
|
+
import { iOSObserve } from '../../../observe/index.js';
|
|
2
|
+
import { iOSInteract } from '../../../interact/index.js';
|
|
3
3
|
|
|
4
4
|
async function main() {
|
|
5
5
|
const deviceId = 'booted';
|
|
@@ -18,7 +18,7 @@ async function main() {
|
|
|
18
18
|
process.exit(3);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const clickable = tree.elements.find(e => e.clickable) || tree.elements[0];
|
|
21
|
+
const clickable = tree.elements.find((e: any) => e.clickable) || tree.elements[0];
|
|
22
22
|
console.log('Using element:', clickable.text || '(no text)', 'clickable=', clickable.clickable, 'center=', clickable.center);
|
|
23
23
|
const [x,y] = clickable.center || [0,0];
|
|
24
24
|
|
package/src/utils/diagnostics.ts
CHANGED
|
@@ -37,7 +37,7 @@ export class DiagnosticError extends Error {
|
|
|
37
37
|
|
|
38
38
|
// Exec ADB with diagnostics — moved from src/android/diagnostics.ts
|
|
39
39
|
import { spawnSync } from 'child_process'
|
|
40
|
-
import { getAdbCmd } from '
|
|
40
|
+
import { getAdbCmd } from './android/utils.js'
|
|
41
41
|
|
|
42
42
|
export function execAdbWithDiagnostics(args: string[], deviceId?: string) {
|
|
43
43
|
const adbArgs = deviceId ? ['-s', deviceId, ...args] : args
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
|
|
3
|
+
export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
|
|
4
|
+
|
|
5
|
+
export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
|
|
6
|
+
const { timeout = 0, env, cwd, shell } = opts
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
|
|
9
|
+
let stdout = ''
|
|
10
|
+
let stderr = ''
|
|
11
|
+
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString() })
|
|
12
|
+
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString() })
|
|
13
|
+
|
|
14
|
+
let timedOut = false
|
|
15
|
+
const timer = timeout && timeout > 0 ? setTimeout(() => {
|
|
16
|
+
timedOut = true
|
|
17
|
+
try { child.kill() } catch { }
|
|
18
|
+
resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() })
|
|
19
|
+
}, timeout) : null
|
|
20
|
+
|
|
21
|
+
child.on('close', (code) => {
|
|
22
|
+
if (timer) clearTimeout(timer)
|
|
23
|
+
if (timedOut) return
|
|
24
|
+
resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
child.on('error', (err) => {
|
|
28
|
+
if (timer) clearTimeout(timer)
|
|
29
|
+
reject(err)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFile, spawn, execSync, spawnSync } from "child_process"
|
|
2
|
-
import { DeviceInfo } from "
|
|
2
|
+
import { DeviceInfo } from "../../types.js"
|
|
3
3
|
import { promises as fsPromises } from 'fs'
|
|
4
4
|
import path from 'path'
|
|
5
|
-
import { makeEnvSnapshot } from '../
|
|
5
|
+
import { makeEnvSnapshot } from '../diagnostics.js'
|
|
6
6
|
|
|
7
7
|
export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun' }
|
|
8
8
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DeviceInfo } from "../types.js"
|
|
2
|
-
import { listAndroidDevices } from "
|
|
3
|
-
import { listIOSDevices } from "
|
|
2
|
+
import { listAndroidDevices } from "./android/utils.js"
|
|
3
|
+
import { listIOSDevices } from "./ios/utils.js"
|
|
4
4
|
|
|
5
5
|
export interface ResolveOptions {
|
|
6
6
|
platform: "android" | "ios"
|
|
@@ -1,4 +1,76 @@
|
|
|
1
|
-
import
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { GetUITreeResponse, GetCurrentScreenResponse, UIElement, SwipeResponse } from '../../types.js'
|
|
3
|
+
|
|
4
|
+
const ANDROID_STRUCTURAL_TYPES = ['Window','Application','View','ViewGroup','LinearLayout','FrameLayout','RelativeLayout','ScrollView','RecyclerView','TextView','ImageView']
|
|
5
|
+
const IOS_STRUCTURAL_TYPES = ['Window','Application','View','ViewController','UITableView','UICollectionView','UILabel','UIImageView','UIView','UIWindow','UIStackView','UITextView','UITableViewCell']
|
|
6
|
+
|
|
7
|
+
function isDynamicText(t?: string): boolean {
|
|
8
|
+
if (!t) return false
|
|
9
|
+
const txt = t.trim()
|
|
10
|
+
if (!txt) return false
|
|
11
|
+
if (/\b\d{1,2}:\d{2}\b/.test(txt)) return true
|
|
12
|
+
if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt)) return true
|
|
13
|
+
if (/^\d+(?:\.\d+)?%$/.test(txt)) return true
|
|
14
|
+
if (/^\d+$/.test(txt)) return true
|
|
15
|
+
if (/^[\d,]{1,10}$/.test(txt)) return true
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeElement(e: UIElement) {
|
|
20
|
+
return {
|
|
21
|
+
type: (e.type || '').toString(),
|
|
22
|
+
resourceId: (e.resourceId || '').toString(),
|
|
23
|
+
text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
|
|
24
|
+
contentDesc: (e.contentDescription || '').toString(),
|
|
25
|
+
bounds: Array.isArray(e.bounds) ? e.bounds.slice(0,4).map((n:any)=>Number(n)||0) : [0,0,0,0]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function computeScreenFingerprint(tree: GetUITreeResponse, current: GetCurrentScreenResponse | null, platform: 'android' | 'ios', limit: number = 50): { fingerprint: string | null; activity?: string; error?: string } {
|
|
30
|
+
try {
|
|
31
|
+
if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
|
|
32
|
+
|
|
33
|
+
const activity = current && (current.activity || (current as any).shortActivity) ? (current.activity || (current as any).shortActivity) : ''
|
|
34
|
+
|
|
35
|
+
const candidates: UIElement[] = (tree.elements || []).filter(e => {
|
|
36
|
+
if (!e) return false
|
|
37
|
+
if (!e.visible) return false
|
|
38
|
+
const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0
|
|
39
|
+
const hasResource = !!e.resourceId
|
|
40
|
+
const interactable = !!e.clickable || !!e.enabled
|
|
41
|
+
const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES
|
|
42
|
+
const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '')
|
|
43
|
+
return interactable || structurallySignificant
|
|
44
|
+
}) as UIElement[]
|
|
45
|
+
|
|
46
|
+
const normalized = candidates.map(normalizeElement)
|
|
47
|
+
|
|
48
|
+
const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0))
|
|
49
|
+
|
|
50
|
+
filteredNormalized.sort((a,b) => {
|
|
51
|
+
const ay = (a.bounds && a.bounds[1]) || 0
|
|
52
|
+
const by = (b.bounds && b.bounds[1]) || 0
|
|
53
|
+
if (ay !== by) return ay - by
|
|
54
|
+
const ax = (a.bounds && a.bounds[0]) || 0
|
|
55
|
+
const bx = (b.bounds && b.bounds[0]) || 0
|
|
56
|
+
return ax - bx
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const limited = filteredNormalized.slice(0, Math.max(0, limit))
|
|
60
|
+
|
|
61
|
+
const payload = {
|
|
62
|
+
activity: platform === 'android' ? (activity || '') : '',
|
|
63
|
+
resolution: (tree as any).resolution || { width: 0, height: 0 },
|
|
64
|
+
elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const combined = JSON.stringify(payload)
|
|
68
|
+
const hash = crypto.createHash('sha256').update(combined).digest('hex')
|
|
69
|
+
return { fingerprint: hash, activity: activity }
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
2
74
|
|
|
3
75
|
export interface ScrollSelector { text?: string; resourceId?: string; contentDesc?: string; className?: string }
|
|
4
76
|
|
|
@@ -84,7 +156,6 @@ export async function scrollToElementShared(opts: {
|
|
|
84
156
|
try {
|
|
85
157
|
await swipe(x1, y1, x2, y2, duration, deviceId)
|
|
86
158
|
} catch (e) {
|
|
87
|
-
// Log swipe failures to aid debugging but don't fail the overall flow
|
|
88
159
|
try { console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`) } catch {}
|
|
89
160
|
}
|
|
90
161
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { AndroidObserve } from "../../src/
|
|
2
|
-
import { AndroidInteract } from "../../src/
|
|
3
|
-
import {
|
|
4
|
-
import { iOSInteract } from "../../src/ios/interact.js";
|
|
1
|
+
import { AndroidObserve, iOSObserve } from "../../src/observe/index.js";
|
|
2
|
+
import { AndroidInteract } from "../../src/interact/index.js";
|
|
3
|
+
import { iOSInteract } from "../../src/interact/index.js";
|
|
5
4
|
import fs from "fs/promises";
|
|
6
5
|
|
|
7
6
|
const androidObserve = new AndroidObserve();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
2
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
3
|
+
|
|
4
|
+
const original = (Observe as any).ToolsObserve.getScreenFingerprintHandler
|
|
5
|
+
|
|
6
|
+
async function runTests() {
|
|
7
|
+
console.log('Starting tests for wait_for_screen_change...')
|
|
8
|
+
|
|
9
|
+
// Test 1: Immediate change
|
|
10
|
+
let seq1: Array<string | null> = ['B','B']
|
|
11
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq1.length ? seq1.shift() : null })
|
|
12
|
+
const start1 = Date.now()
|
|
13
|
+
const res1 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 2000, pollIntervalMs: 50 })
|
|
14
|
+
const elapsed1 = Date.now() - start1
|
|
15
|
+
console.log('Test 1: Immediate change ->', (res1 && (res1 as any).success === true && (res1 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL', 'Elapsed:', elapsed1, 'ms')
|
|
16
|
+
|
|
17
|
+
// Test 2: Transient nulls then stable change
|
|
18
|
+
let seq2: Array<string | null> = [null, null, 'B', 'B']
|
|
19
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq2.length ? seq2.shift() : 'B' })
|
|
20
|
+
const res2 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 3000, pollIntervalMs: 50 })
|
|
21
|
+
console.log('Test 2: Transient nulls ->', (res2 && (res2 as any).success === true && (res2 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL')
|
|
22
|
+
|
|
23
|
+
// Test 3: Timeout
|
|
24
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'A' })
|
|
25
|
+
const res3 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 300, pollIntervalMs: 50 })
|
|
26
|
+
console.log('Test 3: Timeout ->', (res3 && (res3 as any).success === false && (res3 as any).reason === 'timeout') ? 'PASS' : 'FAIL')
|
|
27
|
+
|
|
28
|
+
// Restore original
|
|
29
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = original
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
runTests().catch(console.error)
|
|
@@ -57,7 +57,7 @@ process.exit(0)
|
|
|
57
57
|
// Prefer explicit XCODEBUILD_PATH to ensure deterministic behavior
|
|
58
58
|
process.env.XCODEBUILD_PATH = xcodePath
|
|
59
59
|
|
|
60
|
-
const { ToolsManage } = await import('../../../src/
|
|
60
|
+
const { ToolsManage } = await import('../../../src/manage/index.js')
|
|
61
61
|
|
|
62
62
|
try {
|
|
63
63
|
const ares = await ToolsManage.buildAppHandler({ platform: 'android', projectPath: androidProject })
|
|
@@ -93,7 +93,7 @@ process.exit(0)
|
|
|
93
93
|
process.env.PATH = `${binDir}:${origPath}`
|
|
94
94
|
process.env.XCRUN_PATH = simctlPath
|
|
95
95
|
|
|
96
|
-
const { ToolsManage } = await import('../../../src/
|
|
96
|
+
const { ToolsManage } = await import('../../../src/manage/index.js')
|
|
97
97
|
|
|
98
98
|
try {
|
|
99
99
|
// Android build_and_install
|