mobile-debug-mcp 0.15.0 → 0.17.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.
@@ -1,6 +1,6 @@
1
1
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js";
2
2
  import { AndroidObserve } from "../observe/index.js";
3
- import { scrollToElementShared } from "../interact/shared/scroll_to_element.js";
3
+ import { scrollToElementShared } from "../utils/ui/index.js";
4
4
  export class AndroidInteract {
5
5
  observe = new AndroidObserve();
6
6
  async waitForElement(text, timeout, deviceId) {
@@ -2,6 +2,7 @@ import { AndroidInteract } from './android.js';
2
2
  import { iOSInteract } from './ios.js';
3
3
  export { AndroidInteract, iOSInteract };
4
4
  import { resolveTargetDevice } from '../utils/resolve-device.js';
5
+ import { ToolsObserve } from '../observe/index.js';
5
6
  export class ToolsInteract {
6
7
  static async getInteractionService(platform, deviceId) {
7
8
  const effectivePlatform = platform || 'android';
@@ -34,4 +35,42 @@ export class ToolsInteract {
34
35
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
35
36
  return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id);
36
37
  }
38
+ static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
39
+ const start = Date.now();
40
+ let lastFingerprint = null;
41
+ while (Date.now() - start < timeoutMs) {
42
+ try {
43
+ const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
44
+ const fp = res?.fingerprint ?? null;
45
+ if (fp === null || fp === undefined) {
46
+ lastFingerprint = null;
47
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
48
+ continue;
49
+ }
50
+ lastFingerprint = fp;
51
+ if (fp !== previousFingerprint) {
52
+ // Stability confirmation
53
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
54
+ try {
55
+ const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
56
+ const confirmFp = confirmRes?.fingerprint ?? null;
57
+ if (confirmFp === fp) {
58
+ return { success: true, newFingerprint: fp, elapsedMs: Date.now() - start };
59
+ }
60
+ lastFingerprint = confirmFp;
61
+ continue;
62
+ }
63
+ catch {
64
+ // ignore and continue polling
65
+ continue;
66
+ }
67
+ }
68
+ }
69
+ catch {
70
+ // ignore transient errors
71
+ }
72
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
73
+ }
74
+ return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start };
75
+ }
37
76
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "child_process";
2
2
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
3
3
  import { iOSObserve } from "../observe/index.js";
4
- import { scrollToElementShared } from "../interact/shared/scroll_to_element.js";
4
+ import { scrollToElementShared } from "../utils/ui/index.js";
5
5
  export class iOSInteract {
6
6
  observe = new iOSObserve();
7
7
  async waitForElement(text, timeout, deviceId = "booted") {
@@ -1,72 +1 @@
1
- import crypto from 'crypto';
2
- const ANDROID_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewGroup', 'LinearLayout', 'FrameLayout', 'RelativeLayout', 'ScrollView', 'RecyclerView', 'TextView', 'ImageView'];
3
- const IOS_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewController', 'UITableView', 'UICollectionView', 'UILabel', 'UIImageView', 'UIView', 'UIWindow', 'UIStackView', 'UITextView', 'UITableViewCell'];
4
- function isDynamicText(t) {
5
- if (!t)
6
- return false;
7
- const txt = t.trim();
8
- if (!txt)
9
- return false;
10
- if (/\b\d{1,2}:\d{2}\b/.test(txt))
11
- return true;
12
- if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt))
13
- return true;
14
- if (/^\d+(?:\.\d+)?%$/.test(txt))
15
- return true;
16
- if (/^\d+$/.test(txt))
17
- return true;
18
- if (/^[\d,]{1,10}$/.test(txt))
19
- return true;
20
- return false;
21
- }
22
- function normalizeElement(e) {
23
- return {
24
- type: (e.type || '').toString(),
25
- resourceId: (e.resourceId || '').toString(),
26
- text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
27
- contentDesc: (e.contentDescription || '').toString(),
28
- bounds: Array.isArray(e.bounds) ? e.bounds.slice(0, 4).map((n) => Number(n) || 0) : [0, 0, 0, 0]
29
- };
30
- }
31
- export function computeScreenFingerprint(tree, current, platform, limit = 50) {
32
- try {
33
- if (!tree || tree.error)
34
- return { fingerprint: null, error: tree.error };
35
- const activity = current && (current.activity || current.shortActivity) ? (current.activity || current.shortActivity) : '';
36
- const candidates = (tree.elements || []).filter(e => {
37
- if (!e)
38
- return false;
39
- if (!e.visible)
40
- return false;
41
- const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0;
42
- const hasResource = !!e.resourceId;
43
- const interactable = !!e.clickable || !!e.enabled;
44
- const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES;
45
- const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '');
46
- return interactable || structurallySignificant;
47
- });
48
- const normalized = candidates.map(normalizeElement);
49
- const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0));
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)
54
- return ay - by;
55
- const ax = (a.bounds && a.bounds[0]) || 0;
56
- const bx = (b.bounds && b.bounds[0]) || 0;
57
- return ax - bx;
58
- });
59
- const limited = filteredNormalized.slice(0, Math.max(0, limit));
60
- const payload = {
61
- activity: platform === 'android' ? (activity || '') : '',
62
- resolution: tree.resolution || { width: 0, height: 0 },
63
- elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
64
- };
65
- const combined = JSON.stringify(payload);
66
- const hash = crypto.createHash('sha256').update(combined).digest('hex');
67
- return { fingerprint: hash, activity: activity };
68
- }
69
- catch (e) {
70
- return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
71
- }
72
- }
1
+ export { computeScreenFingerprint } from '../../utils/ui/index.js';
@@ -1,98 +1 @@
1
- export async function scrollToElementShared(opts) {
2
- const { selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId, fetchTree, swipe, stabilizationDelayMs = 350 } = opts;
3
- const matchElement = (el) => {
4
- if (!el)
5
- return false;
6
- if (selector.text !== undefined && selector.text !== el.text)
7
- return false;
8
- if (selector.resourceId !== undefined && selector.resourceId !== el.resourceId)
9
- return false;
10
- if (selector.contentDesc !== undefined && selector.contentDesc !== el.contentDescription)
11
- return false;
12
- if (selector.className !== undefined && selector.className !== el.type)
13
- return false;
14
- return true;
15
- };
16
- const isVisible = (el, resolution) => {
17
- if (!el)
18
- return false;
19
- if (el.visible === false)
20
- return false;
21
- if (!el.bounds || !resolution || !resolution.width || !resolution.height)
22
- return (el.visible === undefined ? true : !!el.visible);
23
- const [left, top, right, bottom] = el.bounds;
24
- const withinY = bottom > 0 && top < resolution.height;
25
- const withinX = right > 0 && left < resolution.width;
26
- return withinX && withinY;
27
- };
28
- const findVisibleMatch = (elements, resolution) => {
29
- if (!Array.isArray(elements))
30
- return null;
31
- for (const e of elements) {
32
- if (matchElement(e) && isVisible(e, resolution))
33
- return e;
34
- }
35
- return null;
36
- };
37
- // Initial check
38
- let tree = await fetchTree();
39
- if (tree.error)
40
- return { success: false, reason: tree.error, scrollsPerformed: 0 };
41
- let found = findVisibleMatch(tree.elements, tree.resolution);
42
- if (found) {
43
- return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed: 0 };
44
- }
45
- const fingerprintOf = (t) => {
46
- try {
47
- return JSON.stringify((t.elements || []).map((e) => ({ text: e.text, resourceId: e.resourceId, bounds: e.bounds })));
48
- }
49
- catch {
50
- return '';
51
- }
52
- };
53
- let prevFingerprint = fingerprintOf(tree);
54
- const width = (tree.resolution && tree.resolution.width) ? tree.resolution.width : 0;
55
- const height = (tree.resolution && tree.resolution.height) ? tree.resolution.height : 0;
56
- const centerX = Math.round(width / 2) || 50;
57
- const clampPct = (v) => Math.max(0.05, Math.min(0.95, v));
58
- const computeCoords = () => {
59
- const defaultStart = direction === 'down' ? 0.8 : 0.2;
60
- const startPct = clampPct(defaultStart);
61
- const endPct = clampPct(defaultStart + (direction === 'down' ? -scrollAmount : scrollAmount));
62
- const x1 = centerX;
63
- const x2 = centerX;
64
- const y1 = Math.round((height || 100) * startPct);
65
- const y2 = Math.round((height || 100) * endPct);
66
- return { x1, y1, x2, y2 };
67
- };
68
- const duration = 300;
69
- let scrollsPerformed = 0;
70
- for (let i = 0; i < maxScrolls; i++) {
71
- const { x1, y1, x2, y2 } = computeCoords();
72
- try {
73
- await swipe(x1, y1, x2, y2, duration, deviceId);
74
- }
75
- catch (e) {
76
- // Log swipe failures to aid debugging but don't fail the overall flow
77
- try {
78
- console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`);
79
- }
80
- catch { }
81
- }
82
- scrollsPerformed++;
83
- await new Promise(resolve => setTimeout(resolve, stabilizationDelayMs));
84
- tree = await fetchTree();
85
- if (tree.error)
86
- return { success: false, reason: tree.error, scrollsPerformed: scrollsPerformed };
87
- found = findVisibleMatch(tree.elements, tree.resolution);
88
- if (found) {
89
- return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed };
90
- }
91
- const fp = fingerprintOf(tree);
92
- if (fp === prevFingerprint) {
93
- return { success: false, reason: 'UI unchanged after scroll; likely end of list', scrollsPerformed: scrollsPerformed };
94
- }
95
- prevFingerprint = fp;
96
- }
97
- return { success: false, reason: 'Element not found after scrolling', scrollsPerformed: scrollsPerformed };
98
- }
1
+ export { scrollToElementShared } from '../../utils/ui/index.js';
@@ -4,7 +4,7 @@ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, get
4
4
  import { createWriteStream } from "fs";
5
5
  import { promises as fsPromises } from "fs";
6
6
  import path from "path";
7
- import { computeScreenFingerprint } from "../interact/shared/fingerprint.js";
7
+ import { computeScreenFingerprint } from "../utils/ui/index.js";
8
8
  const activeLogStreams = new Map();
9
9
  export class AndroidObserve {
10
10
  async getDeviceMetadata(appId, deviceId) {
@@ -82,4 +82,107 @@ export class ToolsObserve {
82
82
  // Both observes implement getScreenFingerprint
83
83
  return await observe.getScreenFingerprint(resolved.id);
84
84
  }
85
+ static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
86
+ const timestamp = Date.now();
87
+ const out = { timestamp, reason: reason || '', activity: null, fingerprint: null, screenshot: null, ui_tree: null, logs: [] };
88
+ // Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
89
+ const sid = sessionId || 'default';
90
+ const tasks = {
91
+ screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
92
+ currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
93
+ fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
94
+ uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
95
+ readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
96
+ };
97
+ const results = await Promise.allSettled(Object.values(tasks));
98
+ const keys = Object.keys(tasks);
99
+ // Map results back to keys
100
+ for (let i = 0; i < results.length; i++) {
101
+ const key = keys[i];
102
+ const res = results[i];
103
+ if (res.status === 'fulfilled') {
104
+ const val = res.value;
105
+ if (key === 'screenshot') {
106
+ out.screenshot = val && val.screenshot ? val.screenshot : null;
107
+ }
108
+ else if (key === 'currentScreen') {
109
+ out.activity = val && ((val.activity || val.shortActivity)) ? (val.activity || val.shortActivity) : out.activity || '';
110
+ }
111
+ else if (key === 'fingerprint') {
112
+ if (val && val.fingerprint)
113
+ out.fingerprint = val.fingerprint;
114
+ if (val && val.activity)
115
+ out.activity = out.activity || val.activity;
116
+ if (val && val.error)
117
+ out.fingerprint_error = val.error;
118
+ }
119
+ else if (key === 'uiTree') {
120
+ out.ui_tree = val;
121
+ if (val && val.error)
122
+ out.ui_tree_error = val.error;
123
+ }
124
+ else if (key === 'readLogStream') {
125
+ // handle below after evaluating fallback
126
+ // temporarily attach to out._streamEntries
127
+ out._streamEntries = val && val.entries ? val.entries : [];
128
+ }
129
+ }
130
+ else {
131
+ const errMsg = res.reason instanceof Error ? res.reason.message : String(res.reason);
132
+ if (key === 'screenshot')
133
+ out.screenshot_error = errMsg;
134
+ if (key === 'currentScreen')
135
+ out.activity_error = errMsg;
136
+ if (key === 'fingerprint') {
137
+ out.fingerprint = null;
138
+ out.fingerprint_error = errMsg;
139
+ }
140
+ if (key === 'uiTree') {
141
+ out.ui_tree = null;
142
+ out.ui_tree_error = errMsg;
143
+ }
144
+ if (key === 'readLogStream') {
145
+ out._streamEntries = [];
146
+ out.logs_error = errMsg;
147
+ }
148
+ }
149
+ }
150
+ // Logs: prefer stream entries, fallback to snapshot logs when empty
151
+ if (includeLogs) {
152
+ try {
153
+ let entries = Array.isArray(out._streamEntries) ? out._streamEntries : [];
154
+ if (!entries || entries.length === 0) {
155
+ const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines });
156
+ const raw = (gl && gl.logs) ? gl.logs : [];
157
+ entries = raw.slice(-Math.max(0, logLines)).map(line => {
158
+ const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO';
159
+ return { timestamp: null, level, message: line };
160
+ });
161
+ }
162
+ else {
163
+ entries = entries.map(ent => {
164
+ const msg = (ent && (ent.message || ent.msg)) ? (ent.message || ent.msg) : (typeof ent === 'string' ? ent : JSON.stringify(ent));
165
+ const levelRaw = (ent && (ent.level || ent.levelName || ent._level)) ? (ent.level || ent.levelName || ent._level) : '';
166
+ const level = (levelRaw && String(levelRaw)).toString().toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO');
167
+ let tsNum = null;
168
+ const maybeIso = ent && (ent._iso || ent.timestamp);
169
+ if (maybeIso && typeof maybeIso === 'string') {
170
+ const d = new Date(maybeIso);
171
+ if (!isNaN(d.getTime()))
172
+ tsNum = d.getTime();
173
+ }
174
+ return { timestamp: tsNum, level, message: msg };
175
+ });
176
+ }
177
+ out.logs = entries;
178
+ }
179
+ catch (e) {
180
+ out.logs = [];
181
+ out.logs_error = e instanceof Error ? e.message : String(e);
182
+ }
183
+ }
184
+ // Clean up internal temporary field
185
+ delete out._streamEntries;
186
+ return out;
187
+ }
85
188
  }
@@ -4,7 +4,7 @@ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcru
4
4
  import { createWriteStream, promises as fsPromises } from 'fs';
5
5
  import path from 'path';
6
6
  import { parseLogLine } from '../utils/android/utils.js';
7
- import { computeScreenFingerprint } from '../interact/shared/fingerprint.js';
7
+ import { computeScreenFingerprint } from '../utils/ui/index.js';
8
8
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
9
9
  function parseIDBFrame(frame) {
10
10
  if (!frame)
package/dist/server.js CHANGED
@@ -195,6 +195,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
195
195
  required: ["platform"]
196
196
  }
197
197
  },
198
+ {
199
+ name: "capture_debug_snapshot",
200
+ description: "Capture a complete debug snapshot (screenshot, ui tree, activity, fingerprint, logs). Returns structured JSON.",
201
+ inputSchema: {
202
+ type: "object",
203
+ properties: {
204
+ reason: { type: "string", description: "Optional reason for snapshot" },
205
+ includeLogs: { type: "boolean", description: "Whether to include logs", default: true },
206
+ logLines: { type: "number", description: "Maximum number of log lines to include", default: 200 },
207
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override" },
208
+ appId: { type: "string", description: "Optional appId to scope logs (package/bundle id)" },
209
+ deviceId: { type: "string", description: "Optional device serial/udid" },
210
+ sessionId: { type: "string", description: "Optional log stream session id to prefer" }
211
+ }
212
+ }
213
+ },
198
214
  {
199
215
  name: "start_log_stream",
200
216
  description: "Start streaming logs for a target application on Android or iOS. For Android this uses adb logcat --pid=<pid>; for iOS it streams `xcrun simctl spawn <device> log stream` with a predicate.",
@@ -273,6 +289,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
273
289
  }
274
290
  }
275
291
  },
292
+ {
293
+ name: "wait_for_screen_change",
294
+ description: "Wait until the current screen fingerprint differs from a provided previousFingerprint. Useful to wait for navigation/animation completion.",
295
+ inputSchema: {
296
+ type: "object",
297
+ properties: {
298
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
299
+ previousFingerprint: { type: "string", description: "The fingerprint to compare against (required)" },
300
+ timeoutMs: { type: "number", description: "Timeout in ms to wait for change (default 5000)", default: 5000 },
301
+ pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300)", default: 300 },
302
+ deviceId: { type: "string", description: "Optional device id/udid to target" }
303
+ },
304
+ required: ["previousFingerprint"]
305
+ }
306
+ },
276
307
  {
277
308
  name: "wait_for_element",
278
309
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -541,6 +572,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
541
572
  ]
542
573
  };
543
574
  }
575
+ if (name === "capture_debug_snapshot") {
576
+ const { reason, includeLogs, logLines, platform, appId, deviceId, sessionId } = args;
577
+ const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, logLines, platform, appId, deviceId, sessionId });
578
+ return wrapResponse(res);
579
+ }
544
580
  if (name === "get_ui_tree") {
545
581
  const { platform, deviceId } = args;
546
582
  const res = await ToolsObserve.getUITreeHandler({ platform, deviceId });
@@ -556,6 +592,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
556
592
  const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
557
593
  return wrapResponse(res);
558
594
  }
595
+ if (name === "wait_for_screen_change") {
596
+ const { platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId } = (args || {});
597
+ const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId });
598
+ return wrapResponse(res);
599
+ }
559
600
  if (name === "wait_for_element") {
560
601
  const { platform, text, timeout, deviceId } = (args || {});
561
602
  const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId });
@@ -1,7 +1,7 @@
1
- import { spawn } from 'child_process';
2
1
  import { promises as fsPromises, existsSync } from 'fs';
3
2
  import path from 'path';
4
3
  import { detectJavaHome } from '../java.js';
4
+ import { execCmd } from '../exec.js';
5
5
  export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
6
6
  /**
7
7
  * Prepare Gradle execution options for building an Android project.
@@ -76,76 +76,20 @@ function getAdbTimeout(args, customTimeout) {
76
76
  return 20000;
77
77
  return 120000;
78
78
  }
79
- export function execAdb(args, deviceId, options = {}) {
79
+ export async function execAdb(args, deviceId, options = {}) {
80
80
  const adbArgs = getAdbArgs(args, deviceId);
81
- return new Promise((resolve, reject) => {
82
- // Extract timeout from options if present, otherwise pass options to spawn
83
- const { timeout: customTimeout, ...spawnOptions } = options;
84
- // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
85
- const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
86
- let stdout = '';
87
- let stderr = '';
88
- if (child.stdout) {
89
- child.stdout.on('data', (data) => {
90
- stdout += data.toString();
91
- });
92
- }
93
- if (child.stderr) {
94
- child.stderr.on('data', (data) => {
95
- stderr += data.toString();
96
- });
97
- }
98
- const timeoutMs = getAdbTimeout(args, customTimeout);
99
- const timeout = setTimeout(() => {
100
- child.kill();
101
- reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
102
- }, timeoutMs);
103
- child.on('close', (code) => {
104
- clearTimeout(timeout);
105
- if (code !== 0) {
106
- // If there's an actual error (non-zero exit code), reject
107
- reject(new Error(stderr.trim() || `Command failed with code ${code}`));
108
- }
109
- else {
110
- // If exit code is 0, resolve with stdout
111
- resolve(stdout.trim());
112
- }
113
- });
114
- child.on('error', (err) => {
115
- clearTimeout(timeout);
116
- reject(err);
117
- });
118
- });
81
+ const timeoutMs = getAdbTimeout(args, options.timeout);
82
+ const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell });
83
+ if (res.exitCode !== 0)
84
+ throw new Error(res.stderr || `Command failed with code ${res.exitCode}`);
85
+ return res.stdout;
119
86
  }
120
87
  // Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
121
- export function spawnAdb(args, deviceId, options = {}) {
88
+ export async function spawnAdb(args, deviceId, options = {}) {
122
89
  const adbArgs = getAdbArgs(args, deviceId);
123
- return new Promise((resolve, reject) => {
124
- const { timeout: customTimeout, ...spawnOptions } = options;
125
- const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
126
- let stdout = '';
127
- let stderr = '';
128
- if (child.stdout)
129
- child.stdout.on('data', d => { stdout += d.toString(); });
130
- if (child.stderr)
131
- child.stderr.on('data', d => { stderr += d.toString(); });
132
- const timeoutMs = getAdbTimeout(args, customTimeout);
133
- const timeout = setTimeout(() => {
134
- try {
135
- child.kill();
136
- }
137
- catch { }
138
- reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
139
- }, timeoutMs);
140
- child.on('close', (code) => {
141
- clearTimeout(timeout);
142
- resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code });
143
- });
144
- child.on('error', (err) => {
145
- clearTimeout(timeout);
146
- reject(err);
147
- });
148
- });
90
+ const timeoutMs = getAdbTimeout(args, options.timeout);
91
+ const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell });
92
+ return { stdout: res.stdout, stderr: res.stderr, code: res.exitCode };
149
93
  }
150
94
  export function getDeviceInfo(deviceId, metadata = {}) {
151
95
  return {
@@ -0,0 +1,34 @@
1
+ import { spawn } from 'child_process';
2
+ export async function execCmd(cmd, args, opts = {}) {
3
+ const { timeout = 0, env, cwd, shell } = opts;
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell });
6
+ let stdout = '';
7
+ let stderr = '';
8
+ if (child.stdout)
9
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
10
+ if (child.stderr)
11
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
12
+ let timedOut = false;
13
+ const timer = timeout && timeout > 0 ? setTimeout(() => {
14
+ timedOut = true;
15
+ try {
16
+ child.kill();
17
+ }
18
+ catch { }
19
+ resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() });
20
+ }, timeout) : null;
21
+ child.on('close', (code) => {
22
+ if (timer)
23
+ clearTimeout(timer);
24
+ if (timedOut)
25
+ return;
26
+ resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() });
27
+ });
28
+ child.on('error', (err) => {
29
+ if (timer)
30
+ clearTimeout(timer);
31
+ reject(err);
32
+ });
33
+ });
34
+ }