mcp-maestro-mobile-ai 1.1.1 → 1.3.1
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 +32 -0
- package/package.json +1 -1
- package/src/mcp-server/index.js +200 -11
- package/src/mcp-server/tools/contextTools.js +123 -0
- package/src/mcp-server/tools/runTools.js +227 -4
- package/src/mcp-server/utils/reportGenerator.js +455 -0
- package/src/mcp-server/utils/yamlTemplate.js +559 -0
|
@@ -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
|
|
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.
|
|
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, '&')
|
|
442
|
+
.replace(/</g, '<')
|
|
443
|
+
.replace(/>/g, '>')
|
|
444
|
+
.replace(/"/g, '"')
|
|
445
|
+
.replace(/'/g, ''');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export default {
|
|
449
|
+
generateJsonReport,
|
|
450
|
+
generateHtmlReport,
|
|
451
|
+
generateReport,
|
|
452
|
+
getReportsDir,
|
|
453
|
+
listReports,
|
|
454
|
+
};
|
|
455
|
+
|