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.
- package/CHANGELOG.md +114 -0
- package/CONTRIBUTING.md +417 -0
- package/LICENSE +22 -0
- package/README.md +719 -0
- package/ROADMAP.md +239 -0
- package/docs/ENTERPRISE_READINESS.md +545 -0
- package/docs/MCP_SETUP.md +180 -0
- package/docs/PRIVACY.md +198 -0
- package/docs/REACT_NATIVE_AUTOMATION_GUIDELINES.md +584 -0
- package/docs/SECURITY.md +573 -0
- package/package.json +69 -0
- package/prompts/example-login-tests.txt +9 -0
- package/prompts/example-youtube-tests.txt +8 -0
- package/src/mcp-server/index.js +625 -0
- package/src/mcp-server/tools/contextTools.js +194 -0
- package/src/mcp-server/tools/promptTools.js +191 -0
- package/src/mcp-server/tools/runTools.js +357 -0
- package/src/mcp-server/tools/utilityTools.js +721 -0
- package/src/mcp-server/tools/validateTools.js +220 -0
- package/src/mcp-server/utils/appContext.js +295 -0
- package/src/mcp-server/utils/logger.js +52 -0
- package/src/mcp-server/utils/maestro.js +508 -0
- package/templates/mcp-config-claude-desktop.json +15 -0
- package/templates/mcp-config-cursor.json +15 -0
- package/templates/mcp-config-vscode.json +13 -0
|
@@ -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
|
+
};
|