mobile-debug-mcp 0.5.0 → 0.7.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/README.md +244 -31
- package/dist/android/interact.js +106 -0
- package/dist/android/observe.js +313 -0
- package/dist/android/utils.js +82 -0
- package/dist/android.js +131 -1
- package/dist/ios/interact.js +135 -0
- package/dist/ios/observe.js +219 -0
- package/dist/ios/utils.js +114 -0
- package/dist/ios.js +134 -0
- package/dist/server.js +271 -27
- package/docs/CHANGELOG.md +11 -1
- package/package.json +2 -1
- package/smoke-test.ts +17 -10
- package/src/android/interact.ts +126 -0
- package/src/android/observe.ts +360 -0
- package/src/android/utils.ts +94 -0
- package/src/ios/interact.ts +153 -0
- package/src/ios/observe.ts +269 -0
- package/src/ios/utils.ts +133 -0
- package/src/server.ts +322 -28
- package/src/types.ts +71 -0
- package/test/run-real-test.js +24 -0
- package/test/wait_for_element_mock.js +113 -0
- package/test/wait_for_element_real.js +67 -0
- package/test-ui-tree.js +68 -0
- package/test-ui-tree.ts +76 -0
- package/tsconfig.json +2 -1
- package/src/android.ts +0 -222
- package/src/ios.ts +0 -243
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { promises as fs } from "fs";
|
|
3
|
+
import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js";
|
|
4
|
+
// --- Helper Functions Specific to Observe ---
|
|
5
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
6
|
+
function parseIDBFrame(frame) {
|
|
7
|
+
if (!frame)
|
|
8
|
+
return [0, 0, 0, 0];
|
|
9
|
+
const x = Number(frame.x || 0);
|
|
10
|
+
const y = Number(frame.y || 0);
|
|
11
|
+
const w = Number(frame.width || frame.w || 0);
|
|
12
|
+
const h = Number(frame.height || frame.h || 0);
|
|
13
|
+
return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
|
|
14
|
+
}
|
|
15
|
+
function getCenter(bounds) {
|
|
16
|
+
const [x1, y1, x2, y2] = bounds;
|
|
17
|
+
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
18
|
+
}
|
|
19
|
+
function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
|
|
20
|
+
if (!node)
|
|
21
|
+
return -1;
|
|
22
|
+
let currentIndex = -1;
|
|
23
|
+
// Prefer standard keys, fallback to alternatives
|
|
24
|
+
const type = node.AXElementType || node.type || "unknown";
|
|
25
|
+
const label = node.AXLabel || node.label || null;
|
|
26
|
+
const value = node.AXValue || null;
|
|
27
|
+
const frame = node.AXFrame || node.frame;
|
|
28
|
+
const traits = node.AXTraits || [];
|
|
29
|
+
const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell"; // Cells are often clickable
|
|
30
|
+
// Filtering Logic:
|
|
31
|
+
// Keep if clickable OR has visible text/label OR has value
|
|
32
|
+
// Also keep 'Window' or 'Application' types as they define the root structure often, though usually depth 0
|
|
33
|
+
const isUseful = clickable || (label && label.length > 0) || (value && value.length > 0) || type === "Application" || type === "Window";
|
|
34
|
+
if (isUseful) {
|
|
35
|
+
const bounds = parseIDBFrame(frame);
|
|
36
|
+
const element = {
|
|
37
|
+
text: label,
|
|
38
|
+
contentDescription: value,
|
|
39
|
+
type: type,
|
|
40
|
+
resourceId: node.AXUniqueId || null,
|
|
41
|
+
clickable: clickable,
|
|
42
|
+
enabled: true, // idb usually returns enabled elements
|
|
43
|
+
visible: true,
|
|
44
|
+
bounds: bounds,
|
|
45
|
+
center: getCenter(bounds),
|
|
46
|
+
depth: depth
|
|
47
|
+
};
|
|
48
|
+
if (parentIndex !== -1) {
|
|
49
|
+
element.parentId = parentIndex;
|
|
50
|
+
}
|
|
51
|
+
elements.push(element);
|
|
52
|
+
currentIndex = elements.length - 1;
|
|
53
|
+
}
|
|
54
|
+
// If current node was skipped, children inherit parentIndex and depth (flattening)
|
|
55
|
+
// If current node was added, children use currentIndex and depth + 1
|
|
56
|
+
const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
|
|
57
|
+
const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
|
|
58
|
+
const childrenIndices = [];
|
|
59
|
+
if (node.children && Array.isArray(node.children)) {
|
|
60
|
+
for (const child of node.children) {
|
|
61
|
+
const childIndex = traverseIDBNode(child, elements, nextParentIndex, nextDepth);
|
|
62
|
+
if (childIndex !== -1) {
|
|
63
|
+
childrenIndices.push(childIndex);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (currentIndex !== -1 && childrenIndices.length > 0) {
|
|
68
|
+
elements[currentIndex].children = childrenIndices;
|
|
69
|
+
}
|
|
70
|
+
return currentIndex;
|
|
71
|
+
}
|
|
72
|
+
// Check if IDB is installed
|
|
73
|
+
async function isIDBInstalled() {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
// Check if 'idb' is in path by trying to run it
|
|
76
|
+
const child = spawn(IDB, ['--version']);
|
|
77
|
+
child.on('error', () => resolve(false));
|
|
78
|
+
child.on('close', (code) => resolve(code === 0));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export class iOSObserve {
|
|
82
|
+
async getDeviceMetadata(deviceId = "booted") {
|
|
83
|
+
return getIOSDeviceMetadata(deviceId);
|
|
84
|
+
}
|
|
85
|
+
async getLogs(appId, deviceId = "booted") {
|
|
86
|
+
// If appId is provided, use predicate filtering
|
|
87
|
+
// Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
|
|
88
|
+
// but we do need to construct the predicate correctly for log show.
|
|
89
|
+
const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m'];
|
|
90
|
+
if (appId) {
|
|
91
|
+
validateBundleId(appId);
|
|
92
|
+
args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
|
|
93
|
+
}
|
|
94
|
+
const result = await execCommand(args, deviceId);
|
|
95
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
96
|
+
const logs = result.output ? result.output.split('\n') : [];
|
|
97
|
+
return {
|
|
98
|
+
device,
|
|
99
|
+
logs,
|
|
100
|
+
logCount: logs.length,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async captureScreenshot(deviceId = "booted") {
|
|
104
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
105
|
+
const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`;
|
|
106
|
+
try {
|
|
107
|
+
// 1. Capture screenshot to temp file
|
|
108
|
+
await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
|
|
109
|
+
// 2. Read file as base64
|
|
110
|
+
const buffer = await fs.readFile(tmpFile);
|
|
111
|
+
const base64 = buffer.toString('base64');
|
|
112
|
+
// 3. Clean up
|
|
113
|
+
await fs.rm(tmpFile).catch(() => { });
|
|
114
|
+
return {
|
|
115
|
+
device,
|
|
116
|
+
screenshot: base64,
|
|
117
|
+
// Default resolution since we can't easily parse it without extra libs
|
|
118
|
+
// Clients will read the real dimensions from the PNG header anyway
|
|
119
|
+
resolution: { width: 0, height: 0 },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
// Ensure cleanup happens even on error
|
|
124
|
+
await fs.rm(tmpFile).catch(() => { });
|
|
125
|
+
throw new Error(`Failed to capture screenshot: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async getUITree(deviceId = "booted") {
|
|
129
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
130
|
+
// idb is required
|
|
131
|
+
const idbExists = await isIDBInstalled();
|
|
132
|
+
if (!idbExists) {
|
|
133
|
+
return {
|
|
134
|
+
device,
|
|
135
|
+
screen: "",
|
|
136
|
+
resolution: { width: 0, height: 0 },
|
|
137
|
+
elements: [],
|
|
138
|
+
error: "iOS UI tree retrieval requires 'idb' (iOS Device Bridge). Please install it via Homebrew: `brew tap facebook/fb && brew install idb-companion` and `pip3 install fb-idb`."
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Resolve UDID if needed
|
|
142
|
+
const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
|
|
143
|
+
// Retry Logic
|
|
144
|
+
let jsonContent = null;
|
|
145
|
+
let attempts = 0;
|
|
146
|
+
const maxAttempts = 3;
|
|
147
|
+
while (attempts < maxAttempts) {
|
|
148
|
+
attempts++;
|
|
149
|
+
try {
|
|
150
|
+
// Stabilization delay
|
|
151
|
+
await delay(300 + (attempts * 100));
|
|
152
|
+
const args = ['ui', 'describe', '--json'];
|
|
153
|
+
if (targetUdid) {
|
|
154
|
+
args.push('--udid', targetUdid);
|
|
155
|
+
}
|
|
156
|
+
const output = await new Promise((resolve, reject) => {
|
|
157
|
+
const child = spawn(IDB, args);
|
|
158
|
+
let stdout = '';
|
|
159
|
+
let stderr = '';
|
|
160
|
+
child.stdout.on('data', (data) => stdout += data.toString());
|
|
161
|
+
child.stderr.on('data', (data) => stderr += data.toString());
|
|
162
|
+
child.on('error', (err) => reject(new Error(`Failed to execute idb: ${err.message}`)));
|
|
163
|
+
child.on('close', (code) => {
|
|
164
|
+
if (code !== 0) {
|
|
165
|
+
reject(new Error(`idb failed (code ${code}): ${stderr.trim()}`));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
resolve(stdout);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
if (output && output.trim().length > 0) {
|
|
173
|
+
jsonContent = JSON.parse(output);
|
|
174
|
+
break; // Success
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
console.error(`Attempt ${attempts} failed: ${err}`);
|
|
179
|
+
}
|
|
180
|
+
if (attempts === maxAttempts) {
|
|
181
|
+
return {
|
|
182
|
+
device,
|
|
183
|
+
screen: "",
|
|
184
|
+
resolution: { width: 0, height: 0 },
|
|
185
|
+
elements: [],
|
|
186
|
+
error: `Failed to retrieve valid UI dump after ${maxAttempts} attempts.`
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const elements = [];
|
|
192
|
+
const root = jsonContent;
|
|
193
|
+
traverseIDBNode(root, elements);
|
|
194
|
+
// Infer resolution from root element if possible (usually the Window/Application frame)
|
|
195
|
+
let width = 0;
|
|
196
|
+
let height = 0;
|
|
197
|
+
if (elements.length > 0) {
|
|
198
|
+
const rootBounds = elements[0].bounds;
|
|
199
|
+
width = rootBounds[2] - rootBounds[0];
|
|
200
|
+
height = rootBounds[3] - rootBounds[1];
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
device,
|
|
204
|
+
screen: "",
|
|
205
|
+
resolution: { width, height },
|
|
206
|
+
elements
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
return {
|
|
211
|
+
device,
|
|
212
|
+
screen: "",
|
|
213
|
+
resolution: { width: 0, height: 0 },
|
|
214
|
+
elements: [],
|
|
215
|
+
error: `Failed to parse idb output: ${e instanceof Error ? e.message : String(e)}`
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { execFile, spawn } from "child_process";
|
|
2
|
+
export const XCRUN = process.env.XCRUN_PATH || "xcrun";
|
|
3
|
+
export const IDB = "idb";
|
|
4
|
+
// Validate bundle ID to prevent any potential injection or invalid characters
|
|
5
|
+
export function validateBundleId(bundleId) {
|
|
6
|
+
if (!bundleId)
|
|
7
|
+
return;
|
|
8
|
+
// Allow alphanumeric, dots, hyphens, and underscores.
|
|
9
|
+
if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
|
|
10
|
+
throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function execCommand(args, deviceId = "booted") {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
// Use spawn for better stream control and consistency with Android implementation
|
|
16
|
+
const child = spawn(XCRUN, args);
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let stderr = '';
|
|
19
|
+
if (child.stdout) {
|
|
20
|
+
child.stdout.on('data', (data) => {
|
|
21
|
+
stdout += data.toString();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (child.stderr) {
|
|
25
|
+
child.stderr.on('data', (data) => {
|
|
26
|
+
stderr += data.toString();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const timeoutMs = args.includes('log') ? 10000 : 5000; // 10s for logs, 5s for others
|
|
30
|
+
const timeout = setTimeout(() => {
|
|
31
|
+
child.kill();
|
|
32
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`));
|
|
33
|
+
}, timeoutMs);
|
|
34
|
+
child.on('close', (code) => {
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
if (code !== 0) {
|
|
37
|
+
reject(new Error(stderr.trim() || `Command failed with code ${code}`));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
child.on('error', (err) => {
|
|
44
|
+
clearTimeout(timeout);
|
|
45
|
+
reject(err);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function parseRuntimeName(runtime) {
|
|
50
|
+
// Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
|
|
51
|
+
try {
|
|
52
|
+
const parts = runtime.split('.');
|
|
53
|
+
const lastPart = parts[parts.length - 1]; // e.g. "iOS-17-0"
|
|
54
|
+
// Split by hyphen to separate OS from version numbers
|
|
55
|
+
// e.g. "iOS-17-0" -> ["iOS", "17", "0"]
|
|
56
|
+
const segments = lastPart.split('-');
|
|
57
|
+
if (segments.length > 1) {
|
|
58
|
+
const os = segments[0]; // "iOS"
|
|
59
|
+
const version = segments.slice(1).join('.'); // "17.0"
|
|
60
|
+
return `${os} ${version}`;
|
|
61
|
+
}
|
|
62
|
+
return lastPart;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return runtime;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
// If deviceId is provided (and not "booted"), we could try to list just that device.
|
|
71
|
+
// But listing all booted devices is usually fine to find the one we want or just one.
|
|
72
|
+
// Let's stick to listing all and filtering if needed, or just return basic info if we can't find it.
|
|
73
|
+
execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
74
|
+
// Default fallback
|
|
75
|
+
const fallback = {
|
|
76
|
+
platform: "ios",
|
|
77
|
+
id: deviceId,
|
|
78
|
+
osVersion: "Unknown",
|
|
79
|
+
model: "Simulator",
|
|
80
|
+
simulator: true,
|
|
81
|
+
};
|
|
82
|
+
if (err || !stdout) {
|
|
83
|
+
resolve(fallback);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const data = JSON.parse(stdout);
|
|
88
|
+
const devicesMap = data.devices || {};
|
|
89
|
+
// Find the device
|
|
90
|
+
for (const runtime in devicesMap) {
|
|
91
|
+
const devices = devicesMap[runtime];
|
|
92
|
+
if (Array.isArray(devices)) {
|
|
93
|
+
for (const device of devices) {
|
|
94
|
+
if (deviceId === "booted" || device.udid === deviceId) {
|
|
95
|
+
resolve({
|
|
96
|
+
platform: "ios",
|
|
97
|
+
id: device.udid,
|
|
98
|
+
osVersion: parseRuntimeName(runtime),
|
|
99
|
+
model: device.name,
|
|
100
|
+
simulator: true,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
resolve(fallback);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
resolve(fallback);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
package/dist/ios.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile, spawn } from "child_process";
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
3
|
const XCRUN = process.env.XCRUN_PATH || "xcrun";
|
|
4
|
+
const IDB = "idb";
|
|
4
5
|
// Validate bundle ID to prevent any potential injection or invalid characters
|
|
5
6
|
function validateBundleId(bundleId) {
|
|
6
7
|
if (!bundleId)
|
|
@@ -208,3 +209,136 @@ export async function captureIOSScreenshot(deviceId = "booted") {
|
|
|
208
209
|
throw new Error(`Failed to capture screenshot: ${err instanceof Error ? err.message : String(err)}`);
|
|
209
210
|
}
|
|
210
211
|
}
|
|
212
|
+
function parseIDBFrame(frame) {
|
|
213
|
+
if (!frame)
|
|
214
|
+
return [0, 0, 0, 0];
|
|
215
|
+
const x = Number(frame.x || 0);
|
|
216
|
+
const y = Number(frame.y || 0);
|
|
217
|
+
const w = Number(frame.width || frame.w || 0);
|
|
218
|
+
const h = Number(frame.height || frame.h || 0);
|
|
219
|
+
return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
|
|
220
|
+
}
|
|
221
|
+
function traverseIDBNode(node, elements, parentIndex = -1) {
|
|
222
|
+
if (!node)
|
|
223
|
+
return -1;
|
|
224
|
+
// Prefer standard keys, fallback to alternatives
|
|
225
|
+
const type = node.AXElementType || node.type || "unknown";
|
|
226
|
+
const label = node.AXLabel || node.label || null;
|
|
227
|
+
const value = node.AXValue || null;
|
|
228
|
+
const frame = node.AXFrame || node.frame;
|
|
229
|
+
const element = {
|
|
230
|
+
text: label,
|
|
231
|
+
contentDescription: value, // iOS uses Value/Label differently than Android but this maps roughly
|
|
232
|
+
type: type,
|
|
233
|
+
resourceId: node.AXUniqueId || null,
|
|
234
|
+
clickable: (node.AXTraits || []).includes("UIAccessibilityTraitButton") || type === "Button",
|
|
235
|
+
enabled: true, // idb usually returns enabled elements
|
|
236
|
+
visible: true,
|
|
237
|
+
bounds: parseIDBFrame(frame),
|
|
238
|
+
};
|
|
239
|
+
if (parentIndex !== -1) {
|
|
240
|
+
element.parentId = parentIndex;
|
|
241
|
+
}
|
|
242
|
+
elements.push(element);
|
|
243
|
+
const currentIndex = elements.length - 1;
|
|
244
|
+
const childrenIndices = [];
|
|
245
|
+
if (node.children && Array.isArray(node.children)) {
|
|
246
|
+
for (const child of node.children) {
|
|
247
|
+
const childIndex = traverseIDBNode(child, elements, currentIndex);
|
|
248
|
+
if (childIndex !== -1) {
|
|
249
|
+
childrenIndices.push(childIndex);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (childrenIndices.length > 0) {
|
|
254
|
+
elements[currentIndex].children = childrenIndices;
|
|
255
|
+
}
|
|
256
|
+
return currentIndex;
|
|
257
|
+
}
|
|
258
|
+
// Check if IDB is installed
|
|
259
|
+
async function isIDBInstalled() {
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
const child = spawn(IDB, ['--version']);
|
|
262
|
+
child.on('error', () => resolve(false));
|
|
263
|
+
child.on('close', (code) => resolve(code === 0));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
export async function getIOSUITree(deviceId = "booted") {
|
|
267
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
268
|
+
// idb is required
|
|
269
|
+
const idbExists = await isIDBInstalled();
|
|
270
|
+
if (!idbExists) {
|
|
271
|
+
return {
|
|
272
|
+
device,
|
|
273
|
+
screen: "",
|
|
274
|
+
resolution: { width: 0, height: 0 },
|
|
275
|
+
elements: [],
|
|
276
|
+
error: "iOS UI tree retrieval requires 'idb' (iOS Device Bridge). Please install it via Homebrew: `brew tap facebook/fb && brew install idb-companion` and `pip3 install fb-idb`."
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
// idb ui describe --udid <uuid> --json
|
|
281
|
+
// If deviceId is 'booted', try to resolve it to a UDID because idb often needs explicit target
|
|
282
|
+
const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
|
|
283
|
+
const args = ['ui', 'describe', '--json'];
|
|
284
|
+
if (targetUdid) {
|
|
285
|
+
args.push('--udid', targetUdid);
|
|
286
|
+
}
|
|
287
|
+
const child = spawn(IDB, args);
|
|
288
|
+
let stdout = '';
|
|
289
|
+
let stderr = '';
|
|
290
|
+
child.stdout.on('data', (data) => stdout += data.toString());
|
|
291
|
+
child.stderr.on('data', (data) => stderr += data.toString());
|
|
292
|
+
child.on('error', (err) => {
|
|
293
|
+
resolve({
|
|
294
|
+
device,
|
|
295
|
+
screen: "",
|
|
296
|
+
resolution: { width: 0, height: 0 },
|
|
297
|
+
elements: [],
|
|
298
|
+
error: `Failed to execute idb: ${err.message}`
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
child.on('close', (code) => {
|
|
302
|
+
if (code !== 0) {
|
|
303
|
+
resolve({
|
|
304
|
+
device,
|
|
305
|
+
screen: "",
|
|
306
|
+
resolution: { width: 0, height: 0 },
|
|
307
|
+
elements: [],
|
|
308
|
+
error: `idb failed (code ${code}): ${stderr.trim()}`
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const json = JSON.parse(stdout);
|
|
314
|
+
const elements = [];
|
|
315
|
+
// idb return object usually has 'children' at root or is the root
|
|
316
|
+
const root = json;
|
|
317
|
+
traverseIDBNode(root, elements);
|
|
318
|
+
// Infer resolution from root element if possible (usually the Window/Application frame)
|
|
319
|
+
let width = 0;
|
|
320
|
+
let height = 0;
|
|
321
|
+
if (elements.length > 0) {
|
|
322
|
+
const rootBounds = elements[0].bounds;
|
|
323
|
+
width = rootBounds[2] - rootBounds[0];
|
|
324
|
+
height = rootBounds[3] - rootBounds[1];
|
|
325
|
+
}
|
|
326
|
+
resolve({
|
|
327
|
+
device,
|
|
328
|
+
screen: "",
|
|
329
|
+
resolution: { width, height },
|
|
330
|
+
elements
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
resolve({
|
|
335
|
+
device,
|
|
336
|
+
screen: "",
|
|
337
|
+
resolution: { width: 0, height: 0 },
|
|
338
|
+
elements: [],
|
|
339
|
+
error: `Failed to parse idb output: ${e instanceof Error ? e.message : String(e)}`
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|