Skip to content

Commit bf34c9f

Browse files
author
GitLab CI
committed
feat: plugin system for custom tools + HTML/JSON/Markdown test report generation
1 parent 3462f2d commit bf34c9f

File tree

1 file changed

+269
-0
lines changed

1 file changed

+269
-0
lines changed

lib/src/cli/server.dart

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2242,8 +2242,37 @@ can visually compare them. Also returns text snapshots for structural comparison
22422242
},
22432243
},
22442244
},
2245+
// === Plugin Tools ===
2246+
{
2247+
"name": "list_plugins",
2248+
"description": "List all loaded custom plugin tools with their descriptions.",
2249+
"inputSchema": {"type": "object", "properties": {}},
2250+
},
2251+
// === Test Report Generation ===
2252+
{
2253+
"name": "generate_report",
2254+
"description": "Generate a test report from recorded test steps and assertions. Supports HTML, JSON, and Markdown formats.",
2255+
"inputSchema": {
2256+
"type": "object",
2257+
"properties": {
2258+
"format": {"type": "string", "enum": ["html", "json", "markdown"], "description": "Report format (default: html)"},
2259+
"title": {"type": "string", "description": "Report title"},
2260+
"output_path": {"type": "string", "description": "Where to save the report file"},
2261+
"include_screenshots": {"type": "boolean", "description": "Embed screenshots in report (default: true)"},
2262+
},
2263+
},
2264+
},
22452265
];
22462266

2267+
// Append plugin-defined tools
2268+
for (final plugin in _pluginTools) {
2269+
allTools.add({
2270+
"name": plugin['name'],
2271+
"description": plugin['description'] ?? 'Custom plugin tool',
2272+
"inputSchema": {"type": "object", "properties": {}},
2273+
});
2274+
}
2275+
22472276
// Smart filtering: when connected, only return relevant tools
22482277
if (!hasConnection) return allTools; // No connection = show all for discovery
22492278

@@ -2282,6 +2311,224 @@ can visually compare them. Also returns text snapshots for structural comparison
22822311
return 'session_${DateTime.now().millisecondsSinceEpoch}';
22832312
}
22842313

2314+
/// Load plugin tools from the plugins directory
2315+
Future<void> _loadPlugins() async {
2316+
final dir = Directory(_pluginsDir);
2317+
if (!await dir.exists()) {
2318+
stderr.writeln('Plugins directory not found: $_pluginsDir (skipping)');
2319+
return;
2320+
}
2321+
await for (final entity in dir.list()) {
2322+
if (entity is File && entity.path.endsWith('.json')) {
2323+
try {
2324+
final content = await entity.readAsString();
2325+
final plugin = jsonDecode(content) as Map<String, dynamic>;
2326+
final name = plugin['name'] as String?;
2327+
final description = plugin['description'] as String? ?? 'Custom plugin';
2328+
final steps = (plugin['steps'] as List<dynamic>?) ?? [];
2329+
if (name == null || steps.isEmpty) continue;
2330+
_pluginTools.add({
2331+
'name': name,
2332+
'description': description,
2333+
'steps': steps,
2334+
'source': entity.path,
2335+
});
2336+
stderr.writeln('Loaded plugin: $name (${steps.length} steps)');
2337+
} catch (e) {
2338+
stderr.writeln('Failed to load plugin ${entity.path}: $e');
2339+
}
2340+
}
2341+
}
2342+
if (_pluginTools.isNotEmpty) {
2343+
stderr.writeln('Loaded ${_pluginTools.length} plugin(s)');
2344+
}
2345+
}
2346+
2347+
/// Execute a plugin by running its steps sequentially
2348+
Future<dynamic> _executePlugin(Map<String, dynamic> plugin, Map<String, dynamic> args) async {
2349+
final steps = (plugin['steps'] as List<dynamic>);
2350+
final results = <Map<String, dynamic>>[];
2351+
for (int i = 0; i < steps.length; i++) {
2352+
final step = steps[i] as Map<String, dynamic>;
2353+
final toolName = step['tool'] as String;
2354+
final toolArgs = Map<String, dynamic>.from((step['args'] as Map<String, dynamic>?) ?? {});
2355+
// Allow overriding step args from the call args
2356+
toolArgs.addAll(args);
2357+
final stopwatch = Stopwatch()..start();
2358+
try {
2359+
final result = await _executeToolInner(toolName, toolArgs);
2360+
stopwatch.stop();
2361+
results.add({
2362+
'step': i + 1,
2363+
'tool': toolName,
2364+
'success': true,
2365+
'result': result,
2366+
'duration_ms': stopwatch.elapsedMilliseconds,
2367+
});
2368+
} catch (e) {
2369+
stopwatch.stop();
2370+
results.add({
2371+
'step': i + 1,
2372+
'tool': toolName,
2373+
'success': false,
2374+
'error': e.toString(),
2375+
'duration_ms': stopwatch.elapsedMilliseconds,
2376+
});
2377+
break;
2378+
}
2379+
}
2380+
final passed = results.where((r) => r['success'] == true).length;
2381+
return {
2382+
'plugin': plugin['name'],
2383+
'steps_total': steps.length,
2384+
'steps_executed': results.length,
2385+
'steps_passed': passed,
2386+
'success': passed == results.length,
2387+
'results': results,
2388+
};
2389+
}
2390+
2391+
/// Generate test report from recorded steps
2392+
Future<dynamic> _generateReport(Map<String, dynamic> args) async {
2393+
final format = (args['format'] as String?) ?? 'html';
2394+
final title = (args['title'] as String?) ?? 'Flutter Skill Test Report';
2395+
final outputPath = args['output_path'] as String?;
2396+
// ignore: unused_local_variable
2397+
final includeScreenshots = (args['include_screenshots'] as bool?) ?? true;
2398+
final now = DateTime.now();
2399+
2400+
final steps = _recordedSteps;
2401+
final passed = steps.where((s) => s['result'] == true).length;
2402+
final failed = steps.length - passed;
2403+
final passRate = steps.isEmpty ? 100.0 : (passed / steps.length * 100);
2404+
2405+
if (format == 'json') {
2406+
final report = {
2407+
'title': title,
2408+
'generated_at': now.toIso8601String(),
2409+
'version': currentVersion,
2410+
'summary': {'total': steps.length, 'passed': passed, 'failed': failed, 'pass_rate': passRate},
2411+
'steps': steps,
2412+
};
2413+
if (outputPath != null) {
2414+
await File(outputPath).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
2415+
return {'format': 'json', 'output_path': outputPath, 'step_count': steps.length};
2416+
}
2417+
return report;
2418+
}
2419+
2420+
if (format == 'markdown') {
2421+
final buf = StringBuffer();
2422+
buf.writeln('# $title');
2423+
buf.writeln('');
2424+
buf.writeln('**Generated:** ${now.toIso8601String()} ');
2425+
buf.writeln('**Version:** flutter-skill v$currentVersion ');
2426+
buf.writeln('**Summary:** $passed passed, $failed failed (${passRate.toStringAsFixed(1)}%)');
2427+
buf.writeln('');
2428+
buf.writeln('| # | Tool | Args | Result | Duration |');
2429+
buf.writeln('|---|------|------|--------|----------|');
2430+
for (final step in steps) {
2431+
final stepNum = step['step'] ?? '-';
2432+
final tool = step['tool'] ?? '';
2433+
final argsStr = jsonEncode(step['params'] ?? {});
2434+
final result = step['result'] == true ? '✅ Pass' : '❌ Fail';
2435+
final dur = step['duration_ms'] ?? '-';
2436+
buf.writeln('| $stepNum | $tool | `$argsStr` | $result | ${dur}ms |');
2437+
}
2438+
final md = buf.toString();
2439+
if (outputPath != null) {
2440+
await File(outputPath).writeAsString(md);
2441+
return {'format': 'markdown', 'output_path': outputPath, 'step_count': steps.length};
2442+
}
2443+
return {'format': 'markdown', 'content': md, 'step_count': steps.length};
2444+
}
2445+
2446+
// HTML format
2447+
final stepsHtml = StringBuffer();
2448+
for (int i = 0; i < steps.length; i++) {
2449+
final step = steps[i];
2450+
final rowClass = i % 2 == 0 ? 'even' : 'odd';
2451+
final resultClass = step['result'] == true ? 'pass' : 'fail';
2452+
final resultText = step['result'] == true ? '✅ Pass' : '❌ Fail';
2453+
final argsStr = _htmlEscape(jsonEncode(step['params'] ?? {}));
2454+
stepsHtml.writeln('<tr class="$rowClass">');
2455+
stepsHtml.writeln(' <td>${step['step'] ?? i + 1}</td>');
2456+
stepsHtml.writeln(' <td><code>${_htmlEscape(step['tool'] ?? '')}</code></td>');
2457+
stepsHtml.writeln(' <td><code>$argsStr</code></td>');
2458+
stepsHtml.writeln(' <td class="$resultClass">$resultText</td>');
2459+
stepsHtml.writeln(' <td>${step['duration_ms'] ?? '-'}ms</td>');
2460+
stepsHtml.writeln('</tr>');
2461+
}
2462+
2463+
final html = '''<!DOCTYPE html>
2464+
<html lang="en">
2465+
<head>
2466+
<meta charset="UTF-8">
2467+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
2468+
<title>${_htmlEscape(title)}</title>
2469+
<style>
2470+
* { margin: 0; padding: 0; box-sizing: border-box; }
2471+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
2472+
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 32px 40px; }
2473+
.header h1 { font-size: 28px; margin-bottom: 8px; }
2474+
.header .meta { opacity: 0.85; font-size: 14px; }
2475+
.summary { display: flex; gap: 24px; padding: 24px 40px; background: white; border-bottom: 1px solid #e2e8f0; }
2476+
.summary .stat { text-align: center; }
2477+
.summary .stat .value { font-size: 32px; font-weight: 700; }
2478+
.summary .stat .label { font-size: 12px; text-transform: uppercase; color: #718096; margin-top: 4px; }
2479+
.stat.passed .value { color: #38a169; }
2480+
.stat.failed .value { color: #e53e3e; }
2481+
.stat.rate .value { color: #667eea; }
2482+
.content { padding: 24px 40px; }
2483+
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
2484+
th { background: #edf2f7; padding: 12px 16px; text-align: left; font-size: 13px; text-transform: uppercase; color: #4a5568; border-bottom: 2px solid #e2e8f0; }
2485+
td { padding: 10px 16px; border-bottom: 1px solid #edf2f7; font-size: 14px; }
2486+
tr.odd { background: #f7fafc; }
2487+
tr.even { background: white; }
2488+
td.pass { color: #38a169; font-weight: 600; }
2489+
td.fail { color: #e53e3e; font-weight: 600; }
2490+
code { background: #edf2f7; padding: 2px 6px; border-radius: 4px; font-size: 12px; word-break: break-all; }
2491+
.footer { padding: 24px 40px; text-align: center; color: #a0aec0; font-size: 13px; }
2492+
.screenshots img { max-width: 200px; cursor: pointer; border-radius: 4px; margin: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); transition: transform 0.2s; }
2493+
.screenshots img:hover { transform: scale(1.05); }
2494+
.screenshots img.expanded { max-width: 100%; }
2495+
</style>
2496+
<script>
2497+
function toggleImg(el) { el.classList.toggle('expanded'); }
2498+
</script>
2499+
</head>
2500+
<body>
2501+
<div class="header">
2502+
<h1>${_htmlEscape(title)}</h1>
2503+
<div class="meta">${now.toIso8601String()} &bull; flutter-skill v$currentVersion</div>
2504+
</div>
2505+
<div class="summary">
2506+
<div class="stat"><div class="value">${steps.length}</div><div class="label">Total Steps</div></div>
2507+
<div class="stat passed"><div class="value">$passed</div><div class="label">Passed</div></div>
2508+
<div class="stat failed"><div class="value">$failed</div><div class="label">Failed</div></div>
2509+
<div class="stat rate"><div class="value">${passRate.toStringAsFixed(1)}%</div><div class="label">Pass Rate</div></div>
2510+
</div>
2511+
<div class="content">
2512+
<table>
2513+
<thead><tr><th>#</th><th>Tool</th><th>Args</th><th>Result</th><th>Duration</th></tr></thead>
2514+
<tbody>$stepsHtml</tbody>
2515+
</table>
2516+
</div>
2517+
<div class="footer">Generated by flutter-skill v$currentVersion</div>
2518+
</body>
2519+
</html>''';
2520+
2521+
if (outputPath != null) {
2522+
await File(outputPath).writeAsString(html);
2523+
return {'format': 'html', 'output_path': outputPath, 'step_count': steps.length};
2524+
}
2525+
return {'format': 'html', 'content': html, 'step_count': steps.length};
2526+
}
2527+
2528+
String _htmlEscape(String text) {
2529+
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
2530+
}
2531+
22852532
/// Check if an error is retryable (transient connection/timeout issues)
22862533
bool _isRetryableError(dynamic error) {
22872534
final msg = error.toString().toLowerCase();
@@ -4798,7 +5045,29 @@ can visually compare them. Also returns text snapshots for structural comparison
47985045
final fc = _asFlutterClient(client!, 'diagnose');
47995046
return await _performDiagnosis(args, fc);
48005047

5048+
case 'list_plugins':
5049+
return {
5050+
'plugins': _pluginTools.map((p) => {
5051+
'name': p['name'],
5052+
'description': p['description'],
5053+
'steps': (p['steps'] as List).length,
5054+
'source': p['source'],
5055+
}).toList(),
5056+
'count': _pluginTools.length,
5057+
};
5058+
5059+
case 'generate_report':
5060+
return await _generateReport(args);
5061+
48015062
default:
5063+
// Check plugin tools
5064+
final plugin = _pluginTools.cast<Map<String, dynamic>?>().firstWhere(
5065+
(p) => p!['name'] == name,
5066+
orElse: () => null,
5067+
);
5068+
if (plugin != null) {
5069+
return await _executePlugin(plugin, args);
5070+
}
48025071
throw Exception("Unknown tool: $name");
48035072
}
48045073
}

0 commit comments

Comments
 (0)