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,721 @@
1
+ /**
2
+ * Utility Tools
3
+ * Helper tools for configuration, results, device management, and cleanup
4
+ */
5
+
6
+ import fs from "fs/promises";
7
+ import path from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { dirname, join } from "path";
10
+ import { logger } from "../utils/logger.js";
11
+ import {
12
+ captureScreenshot as maestroScreenshot,
13
+ checkDeviceConnection,
14
+ checkAppInstalled,
15
+ listDevices as maestroListDevices,
16
+ selectDevice as maestroSelectDevice,
17
+ clearDeviceSelection as maestroClearDevice,
18
+ getSelectedDevice,
19
+ getConfig,
20
+ } from "../utils/maestro.js";
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+ const PROJECT_ROOT = join(__dirname, "../../..");
25
+ const RESULTS_DIR = join(PROJECT_ROOT, "output/results");
26
+ const SCREENSHOTS_DIR = join(PROJECT_ROOT, "output/screenshots");
27
+
28
+ /**
29
+ * Get app configuration
30
+ */
31
+ export async function getAppConfig() {
32
+ try {
33
+ const maestroConfig = getConfig();
34
+
35
+ const config = {
36
+ appId: process.env.APP_ID || null,
37
+ platform: process.env.PLATFORM || "android",
38
+ emulatorName: process.env.EMULATOR_NAME || null,
39
+ androidHome: process.env.ANDROID_HOME || null,
40
+ promptsDir: "prompts",
41
+ outputDir: "output",
42
+ // Include configurable settings
43
+ settings: {
44
+ defaultWaitTimeout: maestroConfig.defaultTimeout,
45
+ defaultRetries: maestroConfig.defaultRetries,
46
+ maxResults: maestroConfig.maxResults,
47
+ },
48
+ // Include selected device
49
+ selectedDevice: maestroConfig.selectedDevice || null,
50
+ };
51
+
52
+ // Check if appId is configured
53
+ if (!config.appId) {
54
+ return {
55
+ content: [
56
+ {
57
+ type: "text",
58
+ text: JSON.stringify({
59
+ success: false,
60
+ error: "APP_ID not configured",
61
+ hint: "Set APP_ID in the .env file or MCP config. Example: APP_ID=com.google.android.youtube",
62
+ config,
63
+ }),
64
+ },
65
+ ],
66
+ };
67
+ }
68
+
69
+ logger.info("App config retrieved", { appId: config.appId });
70
+
71
+ return {
72
+ content: [
73
+ {
74
+ type: "text",
75
+ text: JSON.stringify({
76
+ success: true,
77
+ config,
78
+ usage: `Use appId "${config.appId}" at the top of your Maestro YAML files.`,
79
+ }),
80
+ },
81
+ ],
82
+ };
83
+ } catch (error) {
84
+ logger.error("Error getting app config", { error: error.message });
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: JSON.stringify({
90
+ success: false,
91
+ error: error.message,
92
+ }),
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * List all connected devices
101
+ */
102
+ export async function listDevices() {
103
+ try {
104
+ logger.info("Listing connected devices...");
105
+
106
+ const result = await maestroListDevices();
107
+
108
+ if (!result.success) {
109
+ return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: JSON.stringify({
114
+ success: false,
115
+ error: result.error,
116
+ devices: [],
117
+ }),
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ if (result.devices.length === 0) {
124
+ return {
125
+ content: [
126
+ {
127
+ type: "text",
128
+ text: JSON.stringify({
129
+ success: true,
130
+ devices: [],
131
+ count: 0,
132
+ message: "No devices connected. Start an emulator or connect a physical device via USB.",
133
+ hint: "Enable USB Debugging on your device: Settings → Developer Options → USB Debugging",
134
+ }),
135
+ },
136
+ ],
137
+ };
138
+ }
139
+
140
+ // Format devices for display
141
+ const formattedDevices = result.devices.map((device, index) => ({
142
+ index: index + 1,
143
+ id: device.id,
144
+ type: device.type,
145
+ model: device.model,
146
+ isSelected: device.isSelected,
147
+ displayName: `${device.type === "emulator" ? "📱" : "📲"} ${device.model} (${device.id})`,
148
+ }));
149
+
150
+ return {
151
+ content: [
152
+ {
153
+ type: "text",
154
+ text: JSON.stringify({
155
+ success: true,
156
+ devices: formattedDevices,
157
+ count: result.devices.length,
158
+ selectedDevice: result.selectedDevice,
159
+ message: `Found ${result.devices.length} device(s). Use select_device with a device ID to choose which device to run tests on.`,
160
+ usage: "Example: select_device with deviceId='emulator-5554'",
161
+ }),
162
+ },
163
+ ],
164
+ };
165
+ } catch (error) {
166
+ logger.error("List devices error", { error: error.message });
167
+ return {
168
+ content: [
169
+ {
170
+ type: "text",
171
+ text: JSON.stringify({
172
+ success: false,
173
+ error: error.message,
174
+ }),
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Select a device for running tests
183
+ */
184
+ export async function selectDevice(deviceId) {
185
+ try {
186
+ if (!deviceId) {
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: JSON.stringify({
192
+ success: false,
193
+ error: "Device ID is required",
194
+ hint: "Use list_devices to see available devices and their IDs",
195
+ }),
196
+ },
197
+ ],
198
+ };
199
+ }
200
+
201
+ logger.info(`Selecting device: ${deviceId}`);
202
+
203
+ // Verify the device exists
204
+ const deviceList = await maestroListDevices();
205
+ if (!deviceList.success) {
206
+ return {
207
+ content: [
208
+ {
209
+ type: "text",
210
+ text: JSON.stringify({
211
+ success: false,
212
+ error: "Could not verify device. " + deviceList.error,
213
+ }),
214
+ },
215
+ ],
216
+ };
217
+ }
218
+
219
+ const deviceExists = deviceList.devices.some((d) => d.id === deviceId);
220
+ if (!deviceExists) {
221
+ return {
222
+ content: [
223
+ {
224
+ type: "text",
225
+ text: JSON.stringify({
226
+ success: false,
227
+ error: `Device "${deviceId}" not found`,
228
+ availableDevices: deviceList.devices.map((d) => d.id),
229
+ hint: "Use list_devices to see available devices",
230
+ }),
231
+ },
232
+ ],
233
+ };
234
+ }
235
+
236
+ // Select the device
237
+ const result = maestroSelectDevice(deviceId);
238
+ const selectedDeviceInfo = deviceList.devices.find((d) => d.id === deviceId);
239
+
240
+ return {
241
+ content: [
242
+ {
243
+ type: "text",
244
+ text: JSON.stringify({
245
+ success: true,
246
+ selectedDevice: deviceId,
247
+ deviceInfo: selectedDeviceInfo,
248
+ message: `Device "${deviceId}" selected. All tests will now run on this device.`,
249
+ }),
250
+ },
251
+ ],
252
+ };
253
+ } catch (error) {
254
+ logger.error("Select device error", { error: error.message });
255
+ return {
256
+ content: [
257
+ {
258
+ type: "text",
259
+ text: JSON.stringify({
260
+ success: false,
261
+ error: error.message,
262
+ }),
263
+ },
264
+ ],
265
+ };
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Clear device selection
271
+ */
272
+ export async function clearDevice() {
273
+ try {
274
+ logger.info("Clearing device selection...");
275
+
276
+ const result = maestroClearDevice();
277
+
278
+ return {
279
+ content: [
280
+ {
281
+ type: "text",
282
+ text: JSON.stringify({
283
+ success: true,
284
+ message: "Device selection cleared. Tests will run on the first available device.",
285
+ }),
286
+ },
287
+ ],
288
+ };
289
+ } catch (error) {
290
+ logger.error("Clear device error", { error: error.message });
291
+ return {
292
+ content: [
293
+ {
294
+ type: "text",
295
+ text: JSON.stringify({
296
+ success: false,
297
+ error: error.message,
298
+ }),
299
+ },
300
+ ],
301
+ };
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Check device connection
307
+ */
308
+ export async function checkDevice() {
309
+ try {
310
+ logger.info("Checking device connection...");
311
+
312
+ const deviceStatus = await checkDeviceConnection();
313
+
314
+ if (!deviceStatus.connected) {
315
+ return {
316
+ content: [
317
+ {
318
+ type: "text",
319
+ text: JSON.stringify({
320
+ success: false,
321
+ connected: false,
322
+ error: deviceStatus.error,
323
+ hint:
324
+ deviceStatus.hint ||
325
+ "Start an Android emulator from Android Studio or connect a physical device.",
326
+ troubleshooting: [
327
+ "1. Open Android Studio → Tools → Device Manager",
328
+ "2. Start an emulator or connect a physical device via USB",
329
+ "3. Enable USB Debugging: Settings → Developer Options → USB Debugging",
330
+ "4. Run 'adb devices' to verify connection",
331
+ "5. Try 'adb kill-server && adb start-server' if issues persist",
332
+ ],
333
+ }),
334
+ },
335
+ ],
336
+ };
337
+ }
338
+
339
+ return {
340
+ content: [
341
+ {
342
+ type: "text",
343
+ text: JSON.stringify({
344
+ success: true,
345
+ connected: true,
346
+ devices: deviceStatus.devices,
347
+ activeDevice: deviceStatus.activeDevice,
348
+ selectedDevice: deviceStatus.selectedDevice,
349
+ message: `Found ${deviceStatus.devices.length} connected device(s). ${
350
+ deviceStatus.selectedDevice
351
+ ? `Using selected device: ${deviceStatus.selectedDevice}`
352
+ : "Using first available device."
353
+ }`,
354
+ }),
355
+ },
356
+ ],
357
+ };
358
+ } catch (error) {
359
+ logger.error("Device check error", { error: error.message });
360
+ return {
361
+ content: [
362
+ {
363
+ type: "text",
364
+ text: JSON.stringify({
365
+ success: false,
366
+ error: error.message,
367
+ }),
368
+ },
369
+ ],
370
+ };
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Check if app is installed
376
+ */
377
+ export async function checkApp(appId = null) {
378
+ try {
379
+ const targetAppId = appId || process.env.APP_ID;
380
+
381
+ if (!targetAppId) {
382
+ return {
383
+ content: [
384
+ {
385
+ type: "text",
386
+ text: JSON.stringify({
387
+ success: false,
388
+ error: "No APP_ID provided or configured",
389
+ hint: "Set APP_ID in .env file or pass appId parameter",
390
+ }),
391
+ },
392
+ ],
393
+ };
394
+ }
395
+
396
+ logger.info(`Checking if app is installed: ${targetAppId}`);
397
+
398
+ // First check device connection
399
+ const deviceStatus = await checkDeviceConnection();
400
+ if (!deviceStatus.connected) {
401
+ return {
402
+ content: [
403
+ {
404
+ type: "text",
405
+ text: JSON.stringify({
406
+ success: false,
407
+ error: "No device connected. Cannot check app installation.",
408
+ hint: "Start an emulator or connect a device first.",
409
+ }),
410
+ },
411
+ ],
412
+ };
413
+ }
414
+
415
+ // Check app installation
416
+ const appStatus = await checkAppInstalled(targetAppId);
417
+
418
+ if (!appStatus.installed) {
419
+ return {
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: JSON.stringify({
424
+ success: false,
425
+ installed: false,
426
+ appId: targetAppId,
427
+ device: appStatus.device,
428
+ error: appStatus.error,
429
+ hint:
430
+ appStatus.hint ||
431
+ "Install the app on the device before running tests.",
432
+ }),
433
+ },
434
+ ],
435
+ };
436
+ }
437
+
438
+ return {
439
+ content: [
440
+ {
441
+ type: "text",
442
+ text: JSON.stringify({
443
+ success: true,
444
+ installed: true,
445
+ appId: targetAppId,
446
+ device: appStatus.device,
447
+ message: `App "${targetAppId}" is installed and ready for testing.`,
448
+ }),
449
+ },
450
+ ],
451
+ };
452
+ } catch (error) {
453
+ logger.error("App check error", { error: error.message });
454
+ return {
455
+ content: [
456
+ {
457
+ type: "text",
458
+ text: JSON.stringify({
459
+ success: false,
460
+ error: error.message,
461
+ }),
462
+ },
463
+ ],
464
+ };
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Get test results
470
+ */
471
+ export async function getTestResults(runId = null) {
472
+ try {
473
+ let resultsPath;
474
+
475
+ if (runId) {
476
+ resultsPath = join(RESULTS_DIR, `${runId}.json`);
477
+ } else {
478
+ resultsPath = join(RESULTS_DIR, "latest.json");
479
+ }
480
+
481
+ // Check if results file exists
482
+ try {
483
+ await fs.access(resultsPath);
484
+ } catch {
485
+ return {
486
+ content: [
487
+ {
488
+ type: "text",
489
+ text: JSON.stringify({
490
+ success: false,
491
+ error: runId
492
+ ? `Results not found for run: ${runId}`
493
+ : "No test results available. Run some tests first.",
494
+ }),
495
+ },
496
+ ],
497
+ };
498
+ }
499
+
500
+ const content = await fs.readFile(resultsPath, "utf8");
501
+ const results = JSON.parse(content);
502
+
503
+ logger.info("Test results retrieved", { runId: results.runId });
504
+
505
+ return {
506
+ content: [
507
+ {
508
+ type: "text",
509
+ text: JSON.stringify({
510
+ success: true,
511
+ ...results,
512
+ }),
513
+ },
514
+ ],
515
+ };
516
+ } catch (error) {
517
+ logger.error("Error getting test results", { error: error.message });
518
+ return {
519
+ content: [
520
+ {
521
+ type: "text",
522
+ text: JSON.stringify({
523
+ success: false,
524
+ error: error.message,
525
+ }),
526
+ },
527
+ ],
528
+ };
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Take a screenshot
534
+ */
535
+ export async function takeScreenshot(name) {
536
+ try {
537
+ logger.info(`Taking screenshot: ${name}`);
538
+
539
+ // Check device first
540
+ const deviceStatus = await checkDeviceConnection();
541
+ if (!deviceStatus.connected) {
542
+ return {
543
+ content: [
544
+ {
545
+ type: "text",
546
+ text: JSON.stringify({
547
+ success: false,
548
+ error: "No device connected. Cannot take screenshot.",
549
+ hint: "Start an emulator or connect a device first.",
550
+ }),
551
+ },
552
+ ],
553
+ };
554
+ }
555
+
556
+ const screenshotPath = await maestroScreenshot(name);
557
+
558
+ if (screenshotPath) {
559
+ return {
560
+ content: [
561
+ {
562
+ type: "text",
563
+ text: JSON.stringify({
564
+ success: true,
565
+ path: screenshotPath,
566
+ message: `Screenshot saved: ${screenshotPath}`,
567
+ }),
568
+ },
569
+ ],
570
+ };
571
+ } else {
572
+ return {
573
+ content: [
574
+ {
575
+ type: "text",
576
+ text: JSON.stringify({
577
+ success: false,
578
+ error: "Failed to capture screenshot.",
579
+ }),
580
+ },
581
+ ],
582
+ };
583
+ }
584
+ } catch (error) {
585
+ logger.error("Screenshot error", { error: error.message });
586
+ return {
587
+ content: [
588
+ {
589
+ type: "text",
590
+ text: JSON.stringify({
591
+ success: false,
592
+ error: error.message,
593
+ }),
594
+ },
595
+ ],
596
+ };
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Clean up old test results
602
+ */
603
+ export async function cleanupResults(options = {}) {
604
+ try {
605
+ const maxResults = options.keepLast || getConfig().maxResults || 50;
606
+ const deleteScreenshots = options.deleteScreenshots !== false;
607
+
608
+ logger.info(`Cleaning up results, keeping last ${maxResults}`);
609
+
610
+ // Ensure directories exist
611
+ await fs.mkdir(RESULTS_DIR, { recursive: true });
612
+
613
+ // Get all result files
614
+ const files = await fs.readdir(RESULTS_DIR);
615
+ const resultFiles = files
616
+ .filter((f) => f.endsWith(".json") && f !== "latest.json")
617
+ .map((f) => ({
618
+ name: f,
619
+ path: join(RESULTS_DIR, f),
620
+ }));
621
+
622
+ // Get file stats and sort by modification time
623
+ const filesWithStats = await Promise.all(
624
+ resultFiles.map(async (file) => {
625
+ const stats = await fs.stat(file.path);
626
+ return { ...file, mtime: stats.mtime };
627
+ })
628
+ );
629
+
630
+ filesWithStats.sort((a, b) => b.mtime - a.mtime);
631
+
632
+ // Files to delete (older than maxResults)
633
+ const filesToDelete = filesWithStats.slice(maxResults);
634
+
635
+ let deletedResults = 0;
636
+ let deletedScreenshots = 0;
637
+
638
+ // Delete old result files
639
+ for (const file of filesToDelete) {
640
+ try {
641
+ await fs.unlink(file.path);
642
+ deletedResults++;
643
+ logger.info(`Deleted old result: ${file.name}`);
644
+ } catch (e) {
645
+ logger.warn(`Failed to delete: ${file.name}`);
646
+ }
647
+ }
648
+
649
+ // Optionally clean up screenshots older than 7 days
650
+ if (deleteScreenshots) {
651
+ try {
652
+ const screenshotFiles = await fs.readdir(SCREENSHOTS_DIR);
653
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
654
+
655
+ for (const file of screenshotFiles) {
656
+ const filePath = join(SCREENSHOTS_DIR, file);
657
+ const stats = await fs.stat(filePath);
658
+
659
+ if (stats.mtime.getTime() < sevenDaysAgo) {
660
+ await fs.unlink(filePath);
661
+ deletedScreenshots++;
662
+ }
663
+ }
664
+ } catch (e) {
665
+ // Ignore screenshot cleanup errors
666
+ }
667
+ }
668
+
669
+ const message = [
670
+ `Cleanup complete.`,
671
+ deletedResults > 0
672
+ ? `Deleted ${deletedResults} old result file(s).`
673
+ : "No old results to delete.",
674
+ deletedScreenshots > 0
675
+ ? `Deleted ${deletedScreenshots} old screenshot(s).`
676
+ : "",
677
+ ]
678
+ .filter(Boolean)
679
+ .join(" ");
680
+
681
+ return {
682
+ content: [
683
+ {
684
+ type: "text",
685
+ text: JSON.stringify({
686
+ success: true,
687
+ deletedResults,
688
+ deletedScreenshots,
689
+ keptResults: Math.min(filesWithStats.length, maxResults),
690
+ message,
691
+ }),
692
+ },
693
+ ],
694
+ };
695
+ } catch (error) {
696
+ logger.error("Cleanup error", { error: error.message });
697
+ return {
698
+ content: [
699
+ {
700
+ type: "text",
701
+ text: JSON.stringify({
702
+ success: false,
703
+ error: error.message,
704
+ }),
705
+ },
706
+ ],
707
+ };
708
+ }
709
+ }
710
+
711
+ export default {
712
+ getAppConfig,
713
+ listDevices,
714
+ selectDevice,
715
+ clearDevice,
716
+ checkDevice,
717
+ checkApp,
718
+ getTestResults,
719
+ takeScreenshot,
720
+ cleanupResults,
721
+ };