mcp-maestro-mobile-ai 1.1.1 → 1.3.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.
@@ -4,12 +4,13 @@
4
4
  */
5
5
 
6
6
  import fs from 'fs/promises';
7
- import path from 'path';
8
7
  import { fileURLToPath } from 'url';
9
8
  import { dirname, join } from 'path';
10
9
  import { logger } from '../utils/logger.js';
11
10
  import { runMaestroFlow, checkDeviceConnection, checkAppInstalled, getConfig } from '../utils/maestro.js';
12
11
  import { validateMaestroYaml } from './validateTools.js';
12
+ import { validateYamlStructure } from '../utils/yamlTemplate.js';
13
+ import { generateReport, getReportsDir, listReports } from '../utils/reportGenerator.js';
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = dirname(__filename);
@@ -47,7 +48,49 @@ export async function runTest(yamlContent, testName, options = {}) {
47
48
  };
48
49
  }
49
50
 
50
- // Validate YAML first
51
+ // STEP 1: Validate YAML structure (catches common AI generation errors)
52
+ const structureValidation = validateYamlStructure(yamlContent);
53
+
54
+ if (!structureValidation.valid) {
55
+ logger.warn(`YAML structure validation failed for: ${testName}`, { errors: structureValidation.errors });
56
+
57
+ return {
58
+ content: [
59
+ {
60
+ type: 'text',
61
+ text: JSON.stringify({
62
+ success: false,
63
+ name: testName,
64
+ error: 'YAML STRUCTURE ERROR - Please fix before running',
65
+ structureErrors: structureValidation.errors,
66
+ warnings: structureValidation.warnings,
67
+ summary: structureValidation.summary,
68
+ instructions: `
69
+ ⚠️ YOUR YAML HAS CRITICAL ISSUES. Please follow these rules:
70
+
71
+ 1. ALWAYS use tapOn BEFORE inputText:
72
+ CORRECT:
73
+ - tapOn: "Username"
74
+ - inputText: "value"
75
+
76
+ WRONG (text goes to wrong field!):
77
+ - inputText: "value"
78
+
79
+ 2. ALWAYS start with:
80
+ - clearState
81
+ - launchApp
82
+
83
+ 3. ALWAYS include appId at the top.
84
+
85
+ Please regenerate the YAML following these rules.
86
+ `,
87
+ }),
88
+ },
89
+ ],
90
+ };
91
+ }
92
+
93
+ // STEP 2: Validate YAML syntax
51
94
  const validation = await validateMaestroYaml(yamlContent);
52
95
  const validationResult = JSON.parse(validation.content[0].text);
53
96
 
@@ -59,9 +102,9 @@ export async function runTest(yamlContent, testName, options = {}) {
59
102
  text: JSON.stringify({
60
103
  success: false,
61
104
  name: testName,
62
- error: 'YAML validation failed',
105
+ error: 'YAML syntax validation failed',
63
106
  validationErrors: validationResult.errors,
64
- hint: 'Fix the YAML errors and try again. Common issues: missing appId, invalid syntax.',
107
+ hint: 'Fix the YAML syntax errors and try again.',
65
108
  }),
66
109
  },
67
110
  ],
@@ -351,7 +394,187 @@ async function autoCleanupResults() {
351
394
  }
352
395
  }
353
396
 
397
+ /**
398
+ * Generate a report from test results
399
+ */
400
+ export async function generateTestReport(results, metadata = {}) {
401
+ try {
402
+ const reportResult = await generateReport(results, metadata);
403
+
404
+ return {
405
+ content: [
406
+ {
407
+ type: 'text',
408
+ text: JSON.stringify({
409
+ success: true,
410
+ reportId: reportResult.reportId,
411
+ htmlPath: reportResult.htmlPath,
412
+ jsonPath: reportResult.jsonPath,
413
+ summary: reportResult.summary,
414
+ message: `Report generated successfully! Open the HTML file to view: ${reportResult.htmlPath}`,
415
+ reportsDir: getReportsDir(),
416
+ }),
417
+ },
418
+ ],
419
+ };
420
+ } catch (error) {
421
+ logger.error('Report generation error', { error: error.message });
422
+ return {
423
+ content: [
424
+ {
425
+ type: 'text',
426
+ text: JSON.stringify({
427
+ success: false,
428
+ error: error.message,
429
+ }),
430
+ },
431
+ ],
432
+ };
433
+ }
434
+ }
435
+
436
+ /**
437
+ * List all generated reports
438
+ */
439
+ export async function listTestReports() {
440
+ try {
441
+ const reports = await listReports();
442
+
443
+ return {
444
+ content: [
445
+ {
446
+ type: 'text',
447
+ text: JSON.stringify({
448
+ success: true,
449
+ reportsDir: getReportsDir(),
450
+ reports,
451
+ count: reports.length,
452
+ }),
453
+ },
454
+ ],
455
+ };
456
+ } catch (error) {
457
+ logger.error('List reports error', { error: error.message });
458
+ return {
459
+ content: [
460
+ {
461
+ type: 'text',
462
+ text: JSON.stringify({
463
+ success: false,
464
+ error: error.message,
465
+ }),
466
+ },
467
+ ],
468
+ };
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Run test suite and generate report
474
+ */
475
+ export async function runTestSuiteWithReport(tests, options = {}) {
476
+ try {
477
+ const results = [];
478
+ const startTime = Date.now();
479
+
480
+ for (let i = 0; i < tests.length; i++) {
481
+ const test = tests[i];
482
+ logger.info(`Running test ${i + 1}/${tests.length}: ${test.name}`);
483
+
484
+ // Validate structure first
485
+ const structureValidation = validateYamlStructure(test.yaml);
486
+ if (!structureValidation.valid) {
487
+ results.push({
488
+ name: test.name,
489
+ success: false,
490
+ error: 'YAML structure error: ' + structureValidation.errors.map(e => e.issue).join(', '),
491
+ structureErrors: structureValidation.errors,
492
+ });
493
+ continue;
494
+ }
495
+
496
+ // Validate syntax
497
+ const syntaxValidation = await validateMaestroYaml(test.yaml);
498
+ const syntaxResult = JSON.parse(syntaxValidation.content[0].text);
499
+ if (!syntaxResult.valid) {
500
+ results.push({
501
+ name: test.name,
502
+ success: false,
503
+ error: 'YAML syntax error',
504
+ syntaxErrors: syntaxResult.errors,
505
+ });
506
+ continue;
507
+ }
508
+
509
+ // Run the test
510
+ const testResult = await runMaestroFlow(test.yaml, test.name, {
511
+ retries: options.retries,
512
+ });
513
+
514
+ results.push(testResult);
515
+ }
516
+
517
+ const totalDuration = ((Date.now() - startTime) / 1000).toFixed(2);
518
+
519
+ // Generate report
520
+ const reportResult = await generateReport(results, {
521
+ promptFile: options.promptFile || 'Manual Test Suite',
522
+ appId: options.appId || process.env.APP_ID || 'unknown',
523
+ totalDuration,
524
+ });
525
+
526
+ const passed = results.filter(r => r.success).length;
527
+ const failed = results.length - passed;
528
+
529
+ return {
530
+ content: [
531
+ {
532
+ type: 'text',
533
+ text: JSON.stringify({
534
+ success: failed === 0,
535
+ totalDuration: `${totalDuration}s`,
536
+ summary: {
537
+ total: results.length,
538
+ passed,
539
+ failed,
540
+ passRate: reportResult.summary.passRate,
541
+ },
542
+ report: {
543
+ reportId: reportResult.reportId,
544
+ htmlPath: reportResult.htmlPath,
545
+ jsonPath: reportResult.jsonPath,
546
+ message: `📊 Report generated: ${reportResult.htmlPath}`,
547
+ },
548
+ tests: results.map(r => ({
549
+ name: r.name,
550
+ success: r.success,
551
+ duration: r.duration,
552
+ error: r.error || null,
553
+ })),
554
+ }),
555
+ },
556
+ ],
557
+ };
558
+ } catch (error) {
559
+ logger.error('Test suite with report error', { error: error.message });
560
+ return {
561
+ content: [
562
+ {
563
+ type: 'text',
564
+ text: JSON.stringify({
565
+ success: false,
566
+ error: error.message,
567
+ }),
568
+ },
569
+ ],
570
+ };
571
+ }
572
+ }
573
+
354
574
  export default {
355
575
  runTest,
356
576
  runTestSuite,
577
+ generateTestReport,
578
+ listTestReports,
579
+ runTestSuiteWithReport,
357
580
  };
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Report Generator
3
+ *
4
+ * Generates HTML and JSON reports from test results
5
+ */
6
+
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ import os from 'os';
10
+
11
+ // Report storage location
12
+ const USER_HOME = os.homedir();
13
+ const REPORTS_DIR = path.join(USER_HOME, '.maestro-mcp', 'reports');
14
+
15
+ /**
16
+ * Ensure reports directory exists
17
+ */
18
+ async function ensureReportsDir() {
19
+ await fs.mkdir(REPORTS_DIR, { recursive: true });
20
+ }
21
+
22
+ /**
23
+ * Generate a unique report ID
24
+ */
25
+ function generateReportId() {
26
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
27
+ return `report-${timestamp}`;
28
+ }
29
+
30
+ /**
31
+ * Generate JSON report
32
+ */
33
+ export async function generateJsonReport(results, metadata = {}) {
34
+ await ensureReportsDir();
35
+
36
+ const reportId = generateReportId();
37
+ const report = {
38
+ reportId,
39
+ generatedAt: new Date().toISOString(),
40
+ metadata: {
41
+ promptFile: metadata.promptFile || 'unknown',
42
+ appId: metadata.appId || 'unknown',
43
+ totalTests: results.length,
44
+ ...metadata,
45
+ },
46
+ summary: {
47
+ total: results.length,
48
+ passed: results.filter(r => r.success).length,
49
+ failed: results.filter(r => !r.success).length,
50
+ passRate: results.length > 0
51
+ ? Math.round((results.filter(r => r.success).length / results.length) * 100)
52
+ : 0,
53
+ },
54
+ tests: results.map((result, index) => ({
55
+ index: index + 1,
56
+ name: result.name,
57
+ success: result.success,
58
+ duration: result.duration || 'N/A',
59
+ attempts: result.attempts || 1,
60
+ error: result.error || null,
61
+ screenshot: result.screenshot || null,
62
+ })),
63
+ };
64
+
65
+ const jsonPath = path.join(REPORTS_DIR, `${reportId}.json`);
66
+ await fs.writeFile(jsonPath, JSON.stringify(report, null, 2), 'utf8');
67
+
68
+ return {
69
+ reportId,
70
+ jsonPath,
71
+ report,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Generate HTML report
77
+ */
78
+ export async function generateHtmlReport(results, metadata = {}) {
79
+ await ensureReportsDir();
80
+
81
+ const reportId = generateReportId();
82
+ const timestamp = new Date().toLocaleString();
83
+
84
+ const total = results.length;
85
+ const passed = results.filter(r => r.success).length;
86
+ const failed = total - passed;
87
+ const passRate = total > 0 ? Math.round((passed / total) * 100) : 0;
88
+
89
+ // Generate test rows
90
+ const testRows = results.map((result, index) => {
91
+ const statusClass = result.success ? 'passed' : 'failed';
92
+ const statusIcon = result.success ? '✅' : '❌';
93
+ const errorSection = result.error
94
+ ? `<div class="error-details">${escapeHtml(result.error)}</div>`
95
+ : '';
96
+
97
+ return `
98
+ <tr class="${statusClass}">
99
+ <td>${index + 1}</td>
100
+ <td>${escapeHtml(result.name)}</td>
101
+ <td class="status-${statusClass}">${statusIcon} ${result.success ? 'PASSED' : 'FAILED'}</td>
102
+ <td>${result.duration || 'N/A'}</td>
103
+ <td>${result.attempts || 1}</td>
104
+ <td>${errorSection}</td>
105
+ </tr>
106
+ `;
107
+ }).join('\n');
108
+
109
+ const html = `<!DOCTYPE html>
110
+ <html lang="en">
111
+ <head>
112
+ <meta charset="UTF-8">
113
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
114
+ <title>Test Report - ${reportId}</title>
115
+ <style>
116
+ :root {
117
+ --bg-color: #0d1117;
118
+ --card-bg: #161b22;
119
+ --text-color: #c9d1d9;
120
+ --text-muted: #8b949e;
121
+ --border-color: #30363d;
122
+ --success-color: #238636;
123
+ --success-bg: #1a4721;
124
+ --error-color: #f85149;
125
+ --error-bg: #4a1d1d;
126
+ --primary-color: #58a6ff;
127
+ --header-bg: #21262d;
128
+ }
129
+
130
+ * {
131
+ box-sizing: border-box;
132
+ margin: 0;
133
+ padding: 0;
134
+ }
135
+
136
+ body {
137
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
138
+ background-color: var(--bg-color);
139
+ color: var(--text-color);
140
+ line-height: 1.6;
141
+ padding: 20px;
142
+ }
143
+
144
+ .container {
145
+ max-width: 1200px;
146
+ margin: 0 auto;
147
+ }
148
+
149
+ header {
150
+ background: var(--card-bg);
151
+ border: 1px solid var(--border-color);
152
+ border-radius: 12px;
153
+ padding: 24px;
154
+ margin-bottom: 24px;
155
+ }
156
+
157
+ h1 {
158
+ font-size: 24px;
159
+ font-weight: 600;
160
+ margin-bottom: 8px;
161
+ color: var(--primary-color);
162
+ }
163
+
164
+ .meta {
165
+ color: var(--text-muted);
166
+ font-size: 14px;
167
+ }
168
+
169
+ .summary-cards {
170
+ display: grid;
171
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
172
+ gap: 16px;
173
+ margin-bottom: 24px;
174
+ }
175
+
176
+ .card {
177
+ background: var(--card-bg);
178
+ border: 1px solid var(--border-color);
179
+ border-radius: 12px;
180
+ padding: 20px;
181
+ text-align: center;
182
+ }
183
+
184
+ .card-value {
185
+ font-size: 36px;
186
+ font-weight: 700;
187
+ margin-bottom: 4px;
188
+ }
189
+
190
+ .card-label {
191
+ color: var(--text-muted);
192
+ font-size: 14px;
193
+ text-transform: uppercase;
194
+ letter-spacing: 0.5px;
195
+ }
196
+
197
+ .card-passed .card-value { color: var(--success-color); }
198
+ .card-failed .card-value { color: var(--error-color); }
199
+ .card-rate .card-value { color: var(--primary-color); }
200
+
201
+ .results-table {
202
+ background: var(--card-bg);
203
+ border: 1px solid var(--border-color);
204
+ border-radius: 12px;
205
+ overflow: hidden;
206
+ }
207
+
208
+ table {
209
+ width: 100%;
210
+ border-collapse: collapse;
211
+ }
212
+
213
+ th {
214
+ background: var(--header-bg);
215
+ padding: 14px 16px;
216
+ text-align: left;
217
+ font-weight: 600;
218
+ font-size: 13px;
219
+ text-transform: uppercase;
220
+ letter-spacing: 0.5px;
221
+ color: var(--text-muted);
222
+ border-bottom: 1px solid var(--border-color);
223
+ }
224
+
225
+ td {
226
+ padding: 14px 16px;
227
+ border-bottom: 1px solid var(--border-color);
228
+ font-size: 14px;
229
+ }
230
+
231
+ tr:last-child td {
232
+ border-bottom: none;
233
+ }
234
+
235
+ tr.passed {
236
+ background: var(--success-bg);
237
+ }
238
+
239
+ tr.failed {
240
+ background: var(--error-bg);
241
+ }
242
+
243
+ .status-passed {
244
+ color: var(--success-color);
245
+ font-weight: 600;
246
+ }
247
+
248
+ .status-failed {
249
+ color: var(--error-color);
250
+ font-weight: 600;
251
+ }
252
+
253
+ .error-details {
254
+ background: rgba(0,0,0,0.3);
255
+ padding: 8px 12px;
256
+ border-radius: 6px;
257
+ font-size: 12px;
258
+ font-family: 'Monaco', 'Menlo', monospace;
259
+ color: var(--error-color);
260
+ margin-top: 8px;
261
+ max-width: 400px;
262
+ overflow: auto;
263
+ }
264
+
265
+ footer {
266
+ margin-top: 24px;
267
+ text-align: center;
268
+ color: var(--text-muted);
269
+ font-size: 12px;
270
+ }
271
+
272
+ .progress-bar {
273
+ height: 8px;
274
+ background: var(--border-color);
275
+ border-radius: 4px;
276
+ overflow: hidden;
277
+ margin-top: 10px;
278
+ }
279
+
280
+ .progress-fill {
281
+ height: 100%;
282
+ background: linear-gradient(90deg, var(--success-color), #2ea043);
283
+ transition: width 0.3s ease;
284
+ }
285
+ </style>
286
+ </head>
287
+ <body>
288
+ <div class="container">
289
+ <header>
290
+ <h1>🧪 MCP Maestro Test Report</h1>
291
+ <div class="meta">
292
+ <p><strong>Report ID:</strong> ${reportId}</p>
293
+ <p><strong>Generated:</strong> ${timestamp}</p>
294
+ <p><strong>Prompt File:</strong> ${escapeHtml(metadata.promptFile || 'N/A')}</p>
295
+ <p><strong>App ID:</strong> ${escapeHtml(metadata.appId || 'N/A')}</p>
296
+ </div>
297
+ </header>
298
+
299
+ <div class="summary-cards">
300
+ <div class="card">
301
+ <div class="card-value">${total}</div>
302
+ <div class="card-label">Total Tests</div>
303
+ </div>
304
+ <div class="card card-passed">
305
+ <div class="card-value">${passed}</div>
306
+ <div class="card-label">Passed</div>
307
+ </div>
308
+ <div class="card card-failed">
309
+ <div class="card-value">${failed}</div>
310
+ <div class="card-label">Failed</div>
311
+ </div>
312
+ <div class="card card-rate">
313
+ <div class="card-value">${passRate}%</div>
314
+ <div class="card-label">Pass Rate</div>
315
+ <div class="progress-bar">
316
+ <div class="progress-fill" style="width: ${passRate}%"></div>
317
+ </div>
318
+ </div>
319
+ </div>
320
+
321
+ <div class="results-table">
322
+ <table>
323
+ <thead>
324
+ <tr>
325
+ <th>#</th>
326
+ <th>Test Name</th>
327
+ <th>Status</th>
328
+ <th>Duration</th>
329
+ <th>Attempts</th>
330
+ <th>Details</th>
331
+ </tr>
332
+ </thead>
333
+ <tbody>
334
+ ${testRows}
335
+ </tbody>
336
+ </table>
337
+ </div>
338
+
339
+ <footer>
340
+ <p>Generated by MCP Maestro Mobile AI</p>
341
+ <p>Report Location: ${REPORTS_DIR}</p>
342
+ </footer>
343
+ </div>
344
+ </body>
345
+ </html>`;
346
+
347
+ const htmlPath = path.join(REPORTS_DIR, `${reportId}.html`);
348
+ await fs.writeFile(htmlPath, html, 'utf8');
349
+
350
+ return {
351
+ reportId,
352
+ htmlPath,
353
+ summary: {
354
+ total,
355
+ passed,
356
+ failed,
357
+ passRate,
358
+ },
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Generate both JSON and HTML reports
364
+ */
365
+ export async function generateReport(results, metadata = {}) {
366
+ const jsonResult = await generateJsonReport(results, metadata);
367
+ const htmlResult = await generateHtmlReport(results, metadata);
368
+
369
+ return {
370
+ reportId: jsonResult.reportId,
371
+ jsonPath: jsonResult.jsonPath,
372
+ htmlPath: htmlResult.htmlPath,
373
+ summary: htmlResult.summary,
374
+ report: jsonResult.report,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Get reports directory path
380
+ */
381
+ export function getReportsDir() {
382
+ return REPORTS_DIR;
383
+ }
384
+
385
+ /**
386
+ * List all generated reports
387
+ */
388
+ export async function listReports() {
389
+ await ensureReportsDir();
390
+
391
+ try {
392
+ const files = await fs.readdir(REPORTS_DIR);
393
+ const htmlReports = files.filter(f => f.endsWith('.html'));
394
+
395
+ const reports = [];
396
+ for (const file of htmlReports) {
397
+ const reportId = file.replace('.html', '');
398
+ const jsonPath = path.join(REPORTS_DIR, `${reportId}.json`);
399
+ const htmlPath = path.join(REPORTS_DIR, file);
400
+
401
+ try {
402
+ const jsonContent = await fs.readFile(jsonPath, 'utf8');
403
+ const data = JSON.parse(jsonContent);
404
+ reports.push({
405
+ reportId,
406
+ generatedAt: data.generatedAt,
407
+ promptFile: data.metadata?.promptFile,
408
+ summary: data.summary,
409
+ htmlPath,
410
+ jsonPath,
411
+ });
412
+ } catch {
413
+ // JSON file might not exist
414
+ reports.push({
415
+ reportId,
416
+ htmlPath,
417
+ });
418
+ }
419
+ }
420
+
421
+ // Sort by date (newest first)
422
+ reports.sort((a, b) => {
423
+ if (a.generatedAt && b.generatedAt) {
424
+ return new Date(b.generatedAt) - new Date(a.generatedAt);
425
+ }
426
+ return 0;
427
+ });
428
+
429
+ return reports;
430
+ } catch {
431
+ return [];
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Escape HTML special characters
437
+ */
438
+ function escapeHtml(text) {
439
+ if (!text) return '';
440
+ return String(text)
441
+ .replace(/&/g, '&amp;')
442
+ .replace(/</g, '&lt;')
443
+ .replace(/>/g, '&gt;')
444
+ .replace(/"/g, '&quot;')
445
+ .replace(/'/g, '&#039;');
446
+ }
447
+
448
+ export default {
449
+ generateJsonReport,
450
+ generateHtmlReport,
451
+ generateReport,
452
+ getReportsDir,
453
+ listReports,
454
+ };
455
+