@@ -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 ()} • 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 ('&' , '&' ).replaceAll ('<' , '<' ).replaceAll ('>' , '>' ).replaceAll ('"' , '"' );
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