mcp-maestro-mobile-ai 1.1.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.
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Maestro Utility Functions
3
+ * Execute Maestro CLI commands and handle results
4
+ */
5
+
6
+ import { spawn, execSync } from "child_process";
7
+ import fs from "fs/promises";
8
+ import path from "path";
9
+ import os from "os";
10
+ import { fileURLToPath } from "url";
11
+ import { dirname, join } from "path";
12
+ import { logger } from "./logger.js";
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+
17
+ // Directories - Use system-level hidden paths for temp files
18
+ // This ensures YAML files are not visible in user's project
19
+ const USER_HOME = os.homedir();
20
+ const APP_DATA_DIR = join(USER_HOME, ".maestro-mcp");
21
+ const TEMP_DIR = join(APP_DATA_DIR, "temp");
22
+ const OUTPUT_DIR = join(APP_DATA_DIR, "output");
23
+ const SCREENSHOTS_DIR = join(OUTPUT_DIR, "screenshots");
24
+ const RESULTS_DIR = join(OUTPUT_DIR, "results");
25
+ const CONTEXT_DIR = join(APP_DATA_DIR, "context");
26
+
27
+ // Configuration from environment
28
+ const DEFAULT_TIMEOUT = parseInt(process.env.DEFAULT_WAIT_TIMEOUT) || 10000;
29
+ const DEFAULT_RETRIES = parseInt(process.env.DEFAULT_RETRIES) || 0;
30
+ const MAX_RESULTS = parseInt(process.env.MAX_RESULTS) || 50;
31
+
32
+ // Selected device (in-memory state)
33
+ let selectedDeviceId = process.env.MAESTRO_DEVICE || null;
34
+
35
+ /**
36
+ * Get ADB command path
37
+ * Uses ANDROID_HOME if set, otherwise falls back to 'adb' in PATH
38
+ */
39
+ function getAdbPath() {
40
+ const androidHome = process.env.ANDROID_HOME;
41
+ if (androidHome) {
42
+ // Use path.join for cross-platform compatibility
43
+ return path.join(androidHome, "platform-tools", "adb");
44
+ }
45
+ return "adb";
46
+ }
47
+
48
+ /**
49
+ * Execute Maestro CLI command with optional device targeting
50
+ */
51
+ export function executeMaestro(args, deviceId = null) {
52
+ return new Promise((resolve) => {
53
+ // Use specified device, selected device, or let Maestro choose
54
+ const targetDevice = deviceId || selectedDeviceId;
55
+ const finalArgs = targetDevice ? ["--device", targetDevice, ...args] : args;
56
+
57
+ const maestro = spawn("maestro", finalArgs, {
58
+ shell: true,
59
+ env: { ...process.env },
60
+ });
61
+
62
+ let stdout = "";
63
+ let stderr = "";
64
+
65
+ maestro.stdout.on("data", (data) => {
66
+ stdout += data.toString();
67
+ });
68
+
69
+ maestro.stderr.on("data", (data) => {
70
+ stderr += data.toString();
71
+ });
72
+
73
+ maestro.on("close", (exitCode) => {
74
+ resolve({ exitCode, stdout, stderr });
75
+ });
76
+
77
+ maestro.on("error", (error) => {
78
+ resolve({ exitCode: 1, stdout, stderr: error.message });
79
+ });
80
+ });
81
+ }
82
+
83
+ /**
84
+ * List all connected devices with detailed information
85
+ */
86
+ export async function listDevices() {
87
+ try {
88
+ const adbPath = getAdbPath();
89
+ let adbOutput;
90
+ try {
91
+ adbOutput = execSync(`"${adbPath}" devices -l`, {
92
+ encoding: "utf8",
93
+ timeout: 5000,
94
+ });
95
+ } catch (error) {
96
+ return {
97
+ success: false,
98
+ error:
99
+ "ADB not found. Make sure Android SDK is installed and ANDROID_HOME is set.",
100
+ devices: [],
101
+ };
102
+ }
103
+
104
+ // Parse ADB output with detailed info
105
+ const lines = adbOutput.split("\n").filter((line) => line.trim());
106
+ const devices = lines
107
+ .slice(1) // Skip header "List of devices attached"
108
+ .map((line) => {
109
+ // Format: "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1"
110
+ // Or: "RF8M12345XY device usb:1-1 product:starqltesq model:SM_G965U device:starqltesq transport_id:2"
111
+ const parts = line.split(/\s+/);
112
+ if (parts.length >= 2 && parts[1] === "device") {
113
+ const id = parts[0];
114
+ const isEmulator = id.startsWith("emulator-");
115
+
116
+ // Extract model name if available
117
+ const modelMatch = line.match(/model:(\S+)/);
118
+ const model = modelMatch ? modelMatch[1].replace(/_/g, " ") : null;
119
+
120
+ // Extract product name
121
+ const productMatch = line.match(/product:(\S+)/);
122
+ const product = productMatch ? productMatch[1] : null;
123
+
124
+ return {
125
+ id,
126
+ type: isEmulator ? "emulator" : "physical",
127
+ model:
128
+ model || (isEmulator ? "Android Emulator" : "Unknown Device"),
129
+ product,
130
+ status: "connected",
131
+ isSelected: id === selectedDeviceId,
132
+ };
133
+ }
134
+ return null;
135
+ })
136
+ .filter((d) => d !== null);
137
+
138
+ logger.info(`Found ${devices.length} device(s)`);
139
+
140
+ return {
141
+ success: true,
142
+ devices,
143
+ count: devices.length,
144
+ selectedDevice: selectedDeviceId,
145
+ };
146
+ } catch (error) {
147
+ logger.error("List devices failed", { error: error.message });
148
+ return {
149
+ success: false,
150
+ error: error.message,
151
+ devices: [],
152
+ };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Select a device for running tests
158
+ */
159
+ export function selectDevice(deviceId) {
160
+ selectedDeviceId = deviceId;
161
+ logger.info(`Device selected: ${deviceId}`);
162
+ return {
163
+ success: true,
164
+ selectedDevice: deviceId,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Get the currently selected device
170
+ */
171
+ export function getSelectedDevice() {
172
+ return selectedDeviceId;
173
+ }
174
+
175
+ /**
176
+ * Clear device selection (use default)
177
+ */
178
+ export function clearDeviceSelection() {
179
+ selectedDeviceId = null;
180
+ logger.info("Device selection cleared, will use default");
181
+ return {
182
+ success: true,
183
+ message:
184
+ "Device selection cleared. Maestro will use the first available device.",
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Check if ADB is available and device is connected
190
+ */
191
+ export async function checkDeviceConnection() {
192
+ try {
193
+ const adbPath = getAdbPath();
194
+ // Check if ADB is available
195
+ let adbOutput;
196
+ try {
197
+ adbOutput = execSync(`"${adbPath}" devices`, {
198
+ encoding: "utf8",
199
+ timeout: 5000,
200
+ });
201
+ } catch (error) {
202
+ return {
203
+ connected: false,
204
+ error:
205
+ "ADB not found. Make sure Android SDK is installed and ANDROID_HOME is set.",
206
+ devices: [],
207
+ };
208
+ }
209
+
210
+ // Parse ADB output
211
+ const lines = adbOutput.split("\n").filter((line) => line.trim());
212
+ const devices = lines
213
+ .slice(1) // Skip header
214
+ .map((line) => {
215
+ const parts = line.split("\t");
216
+ if (parts.length >= 2) {
217
+ return {
218
+ id: parts[0].trim(),
219
+ status: parts[1].trim(),
220
+ };
221
+ }
222
+ return null;
223
+ })
224
+ .filter((d) => d && d.status === "device");
225
+
226
+ if (devices.length === 0) {
227
+ return {
228
+ connected: false,
229
+ error:
230
+ "No Android device/emulator connected. Start an emulator or connect a device.",
231
+ devices: [],
232
+ hint: "Run: emulator -avd YOUR_AVD_NAME",
233
+ };
234
+ }
235
+
236
+ logger.info(`Found ${devices.length} connected device(s)`);
237
+
238
+ return {
239
+ connected: true,
240
+ devices,
241
+ activeDevice: selectedDeviceId || devices[0].id,
242
+ selectedDevice: selectedDeviceId,
243
+ };
244
+ } catch (error) {
245
+ logger.error("Device check failed", { error: error.message });
246
+ return {
247
+ connected: false,
248
+ error: error.message,
249
+ devices: [],
250
+ };
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Check if app is installed on device (optionally on specific device)
256
+ */
257
+ export async function checkAppInstalled(appId, deviceId = null) {
258
+ if (!appId) {
259
+ return {
260
+ installed: false,
261
+ error: "APP_ID not configured",
262
+ hint: "Set APP_ID in .env file or MCP config",
263
+ };
264
+ }
265
+
266
+ const targetDevice = deviceId || selectedDeviceId;
267
+ const adbPath = getAdbPath();
268
+ const adbPrefix = targetDevice
269
+ ? `"${adbPath}" -s ${targetDevice}`
270
+ : `"${adbPath}"`;
271
+
272
+ try {
273
+ const result = execSync(`${adbPrefix} shell pm list packages ${appId}`, {
274
+ encoding: "utf8",
275
+ timeout: 10000,
276
+ });
277
+
278
+ const isInstalled = result.includes(appId);
279
+
280
+ if (!isInstalled) {
281
+ return {
282
+ installed: false,
283
+ appId,
284
+ device: targetDevice,
285
+ error: `App "${appId}" is not installed on the device`,
286
+ hint: "Install the app on the device before running tests",
287
+ };
288
+ }
289
+
290
+ logger.info(
291
+ `App verified: ${appId} on device: ${targetDevice || "default"}`
292
+ );
293
+
294
+ return {
295
+ installed: true,
296
+ appId,
297
+ device: targetDevice,
298
+ };
299
+ } catch (error) {
300
+ // If command fails, try alternative approach
301
+ try {
302
+ const result = execSync(`${adbPrefix} shell pm list packages`, {
303
+ encoding: "utf8",
304
+ timeout: 15000,
305
+ });
306
+
307
+ const isInstalled = result.includes(appId);
308
+
309
+ return {
310
+ installed: isInstalled,
311
+ appId,
312
+ device: targetDevice,
313
+ error: isInstalled
314
+ ? null
315
+ : `App "${appId}" is not installed on the device`,
316
+ };
317
+ } catch (e) {
318
+ return {
319
+ installed: false,
320
+ appId,
321
+ error:
322
+ "Could not verify app installation. Make sure device is connected.",
323
+ };
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Run a Maestro flow from YAML content with retry support
330
+ */
331
+ export async function runMaestroFlow(yamlContent, testName, options = {}) {
332
+ const maxRetries = options.retries ?? DEFAULT_RETRIES;
333
+ const deviceId = options.deviceId || selectedDeviceId;
334
+ const startTime = Date.now();
335
+
336
+ // Ensure directories exist
337
+ await fs.mkdir(TEMP_DIR, { recursive: true });
338
+ await fs.mkdir(SCREENSHOTS_DIR, { recursive: true });
339
+
340
+ // Write YAML to temp file
341
+ const tempFile = join(TEMP_DIR, `${testName}-${Date.now()}.yaml`);
342
+ await fs.writeFile(tempFile, yamlContent, "utf8");
343
+
344
+ let lastResult = null;
345
+ let attempts = 0;
346
+
347
+ try {
348
+ // Retry loop
349
+ while (attempts <= maxRetries) {
350
+ attempts++;
351
+
352
+ if (attempts > 1) {
353
+ logger.info(
354
+ `Retry ${attempts - 1}/${maxRetries} for test: ${testName}`
355
+ );
356
+ }
357
+
358
+ // Execute Maestro with device targeting
359
+ const result = await executeMaestro(["test", tempFile], deviceId);
360
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
361
+
362
+ if (result.exitCode === 0) {
363
+ logger.info(
364
+ `Test passed: ${testName} in ${duration}s (attempt ${attempts})${
365
+ deviceId ? ` on device ${deviceId}` : ""
366
+ }`
367
+ );
368
+ return {
369
+ success: true,
370
+ name: testName,
371
+ duration,
372
+ attempts,
373
+ device: deviceId || "default",
374
+ output: result.stdout,
375
+ };
376
+ }
377
+
378
+ // Test failed
379
+ lastResult = {
380
+ success: false,
381
+ name: testName,
382
+ duration,
383
+ attempts,
384
+ device: deviceId || "default",
385
+ error: parseErrorMessage(result.stderr || result.stdout),
386
+ rawError: result.stderr || result.stdout,
387
+ };
388
+
389
+ // If we have more retries, continue
390
+ if (attempts <= maxRetries) {
391
+ logger.warn(`Test failed, will retry: ${testName}`);
392
+ // Small delay before retry
393
+ await new Promise((resolve) => setTimeout(resolve, 2000));
394
+ }
395
+ }
396
+
397
+ // All retries exhausted
398
+ logger.error(`Test failed after ${attempts} attempts: ${testName}`);
399
+
400
+ // Capture failure screenshot
401
+ const screenshotPath = await captureScreenshot(
402
+ `${testName}-failure`,
403
+ deviceId
404
+ );
405
+
406
+ return {
407
+ ...lastResult,
408
+ screenshot: screenshotPath,
409
+ retriesExhausted: maxRetries > 0,
410
+ };
411
+ } finally {
412
+ // Clean up temp file
413
+ try {
414
+ await fs.unlink(tempFile);
415
+ } catch (e) {
416
+ // Ignore cleanup errors
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Parse and improve error messages
423
+ */
424
+ function parseErrorMessage(rawError) {
425
+ if (!rawError) return "Unknown error";
426
+
427
+ // Element not found
428
+ if (rawError.includes("Element not found")) {
429
+ const match = rawError.match(/Element not found: (.+)/);
430
+ const element = match ? match[1] : "unknown element";
431
+ return `Element not found: ${element}. The element may not exist, or the app may still be loading. Try adding more wait time or check if the selector is correct.`;
432
+ }
433
+
434
+ // Timeout
435
+ if (rawError.includes("timeout") || rawError.includes("Timeout")) {
436
+ return "Operation timed out. The app may be slow or the element took too long to appear. Consider increasing the timeout value.";
437
+ }
438
+
439
+ // App not installed
440
+ if (
441
+ rawError.includes("App not installed") ||
442
+ rawError.includes("could not be found")
443
+ ) {
444
+ return "App is not installed on the device. Install the app before running tests.";
445
+ }
446
+
447
+ // No device
448
+ if (rawError.includes("No device") || rawError.includes("no devices")) {
449
+ return "No Android device/emulator connected. Start an emulator or connect a device.";
450
+ }
451
+
452
+ // Truncate very long errors
453
+ if (rawError.length > 500) {
454
+ return rawError.substring(0, 500) + "... (truncated)";
455
+ }
456
+
457
+ return rawError;
458
+ }
459
+
460
+ /**
461
+ * Capture a screenshot (optionally on specific device)
462
+ */
463
+ export async function captureScreenshot(name, deviceId = null) {
464
+ await fs.mkdir(SCREENSHOTS_DIR, { recursive: true });
465
+
466
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
467
+ const screenshotPath = join(SCREENSHOTS_DIR, `${name}-${timestamp}.png`);
468
+
469
+ try {
470
+ const result = await executeMaestro(
471
+ ["screenshot", screenshotPath],
472
+ deviceId
473
+ );
474
+ if (result.exitCode === 0) {
475
+ logger.info(`Screenshot saved: ${screenshotPath}`);
476
+ return screenshotPath;
477
+ }
478
+ return null;
479
+ } catch (error) {
480
+ logger.warn("Failed to capture screenshot", { error: error.message });
481
+ return null;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Get configuration values
487
+ */
488
+ export function getConfig() {
489
+ return {
490
+ defaultTimeout: DEFAULT_TIMEOUT,
491
+ defaultRetries: DEFAULT_RETRIES,
492
+ maxResults: MAX_RESULTS,
493
+ selectedDevice: selectedDeviceId,
494
+ };
495
+ }
496
+
497
+ export default {
498
+ executeMaestro,
499
+ runMaestroFlow,
500
+ captureScreenshot,
501
+ checkDeviceConnection,
502
+ checkAppInstalled,
503
+ listDevices,
504
+ selectDevice,
505
+ getSelectedDevice,
506
+ clearDeviceSelection,
507
+ getConfig,
508
+ };
@@ -0,0 +1,15 @@
1
+ {
2
+ "mcpServers": {
3
+ "maestro": {
4
+ "command": "npx",
5
+ "args": ["mcp-maestro-mobile-ai"],
6
+ "env": {
7
+ "APP_ID": "com.your.app.package",
8
+ "ANDROID_HOME": "/path/to/android/sdk",
9
+ "DEFAULT_RETRIES": "1",
10
+ "MAX_RESULTS": "50"
11
+ }
12
+ }
13
+ }
14
+ }
15
+
@@ -0,0 +1,15 @@
1
+ {
2
+ "mcpServers": {
3
+ "maestro": {
4
+ "command": "npx",
5
+ "args": ["mcp-maestro-mobile-ai"],
6
+ "env": {
7
+ "APP_ID": "com.your.app.package",
8
+ "ANDROID_HOME": "/path/to/android/sdk",
9
+ "DEFAULT_RETRIES": "1",
10
+ "MAX_RESULTS": "50"
11
+ }
12
+ }
13
+ }
14
+ }
15
+
@@ -0,0 +1,13 @@
1
+ {
2
+ "github.copilot.chat.mcpServers": {
3
+ "maestro": {
4
+ "command": "npx",
5
+ "args": ["mcp-maestro-mobile-ai"],
6
+ "env": {
7
+ "APP_ID": "com.your.app.package",
8
+ "ANDROID_HOME": "/path/to/android/sdk"
9
+ }
10
+ }
11
+ }
12
+ }
13
+