Skip to content

Commit 7463ac5

Browse files
author
GitLab CI
committed
feat: multi_platform_test and compare_platforms for cross-platform testing
1 parent afc5799 commit 7463ac5

File tree

1 file changed

+259
-0
lines changed

1 file changed

+259
-0
lines changed

lib/src/cli/server.dart

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ class FlutterMcpServer {
160160
String? _autoConnectUrl;
161161
int? _autoConnectCdpPort;
162162

163+
// Plugin system
164+
String _pluginsDir = '${Platform.environment['HOME'] ?? '.'}/.flutter-skill/plugins';
165+
final List<Map<String, dynamic>> _pluginTools = [];
166+
163167
// Last known connection info for auto-reconnect
164168
String? _lastConnectionUri;
165169
int? _lastConnectionPort;
@@ -2098,6 +2102,72 @@ Detailed diagnostic report with:
20982102
"inputSchema": {"type": "object", "properties": {}},
20992103
},
21002104

2105+
// AI Visual Verification
2106+
{
2107+
"name": "visual_verify",
2108+
"description": """Take a screenshot AND text snapshot for AI visual verification.
2109+
2110+
Returns both a screenshot file and structured text snapshot so the calling AI can verify
2111+
the UI matches the expected description. Optionally checks for specific elements.
2112+
2113+
[USE WHEN]
2114+
• Verifying UI looks correct after a series of actions
2115+
• Checking that expected elements are present on screen
2116+
• Visual QA of a screen against a description
2117+
2118+
[RETURNS]
2119+
Combined result with screenshot path, text snapshot, element matching results, and a hint
2120+
for the AI to compare against the provided description.""",
2121+
"inputSchema": {
2122+
"type": "object",
2123+
"properties": {
2124+
"description": {
2125+
"type": "string",
2126+
"description": "What the UI should look like (e.g., 'login form with email and password fields')"
2127+
},
2128+
"check_elements": {
2129+
"type": "array",
2130+
"items": {"type": "string"},
2131+
"description": "Specific elements that should be visible (matched against snapshot refs and text)"
2132+
},
2133+
"quality": {
2134+
"type": "number",
2135+
"description": "Screenshot quality 0-1 (default 0.5)"
2136+
},
2137+
},
2138+
},
2139+
},
2140+
{
2141+
"name": "visual_diff",
2142+
"description": """Compare current screen against a baseline screenshot.
2143+
2144+
Takes a new screenshot and returns both the current and baseline paths so the calling AI
2145+
can visually compare them. Also returns text snapshots for structural comparison.
2146+
2147+
[USE WHEN]
2148+
• Visual regression testing
2149+
• Comparing before/after states
2150+
• Verifying no unintended UI changes""",
2151+
"inputSchema": {
2152+
"type": "object",
2153+
"properties": {
2154+
"baseline_path": {
2155+
"type": "string",
2156+
"description": "Path to baseline screenshot file"
2157+
},
2158+
"description": {
2159+
"type": "string",
2160+
"description": "What to focus on when comparing (optional)"
2161+
},
2162+
"quality": {
2163+
"type": "number",
2164+
"description": "Screenshot quality 0-1 (default 0.5)"
2165+
},
2166+
},
2167+
"required": ["baseline_path"],
2168+
},
2169+
},
2170+
21012171
// Parallel Multi-Device
21022172
{
21032173
"name": "parallel_snapshot",
@@ -2122,6 +2192,52 @@ Detailed diagnostic report with:
21222192
},
21232193
},
21242194
},
2195+
2196+
// Cross-Platform Test Orchestration
2197+
{
2198+
"name": "multi_platform_test",
2199+
"description": "Run the same test steps across all connected platforms simultaneously. Great for cross-platform verification.",
2200+
"inputSchema": {
2201+
"type": "object",
2202+
"properties": {
2203+
"actions": {
2204+
"type": "array",
2205+
"items": {
2206+
"type": "object",
2207+
"properties": {
2208+
"tool": {"type": "string"},
2209+
"args": {"type": "object"},
2210+
},
2211+
},
2212+
"description": "Sequence of tool calls to execute on each platform"
2213+
},
2214+
"session_ids": {
2215+
"type": "array",
2216+
"items": {"type": "string"},
2217+
"description": "Specific sessions to test (default: all connected)"
2218+
},
2219+
"stop_on_failure": {
2220+
"type": "boolean",
2221+
"description": "Stop all platforms on first failure (default: false)"
2222+
},
2223+
},
2224+
"required": ["actions"],
2225+
},
2226+
},
2227+
{
2228+
"name": "compare_platforms",
2229+
"description": "Take snapshots from all connected platforms and compare element presence. Identifies cross-platform inconsistencies.",
2230+
"inputSchema": {
2231+
"type": "object",
2232+
"properties": {
2233+
"session_ids": {
2234+
"type": "array",
2235+
"items": {"type": "string"},
2236+
"description": "Specific sessions to compare (default: all connected)"
2237+
},
2238+
},
2239+
},
2240+
},
21252241
];
21262242

21272243
// Smart filtering: when connected, only return relevant tools
@@ -3547,6 +3663,149 @@ Detailed diagnostic report with:
35473663
return {"results": results};
35483664
}
35493665

3666+
if (name == 'multi_platform_test') {
3667+
final actions = (args['actions'] as List<dynamic>?) ?? [];
3668+
final sessionIds = (args['session_ids'] as List<dynamic>?)?.cast<String>() ?? _sessions.keys.toList();
3669+
final stopOnFailure = args['stop_on_failure'] as bool? ?? false;
3670+
final savedSessionId = _activeSessionId;
3671+
3672+
final futures = sessionIds.map((sid) async {
3673+
final platform = _sessions[sid]?.deviceId ?? 'unknown';
3674+
final steps = <Map<String, dynamic>>[];
3675+
int passed = 0;
3676+
int failed = 0;
3677+
bool stopped = false;
3678+
3679+
for (final action in actions) {
3680+
if (stopped) break;
3681+
final toolName = (action as Map<String, dynamic>)['tool'] as String? ?? '';
3682+
final toolArgs = Map<String, dynamic>.from(
3683+
(action['args'] as Map<String, dynamic>?) ?? {},
3684+
);
3685+
toolArgs['session_id'] = sid;
3686+
3687+
final sw = Stopwatch()..start();
3688+
try {
3689+
// Temporarily switch active session for tools that rely on it
3690+
_activeSessionId = sid;
3691+
final result = await _executeToolInner(toolName, toolArgs);
3692+
sw.stop();
3693+
final success = result is Map ? (result['error'] == null) : true;
3694+
steps.add({'tool': toolName, 'success': success, 'time_ms': sw.elapsedMilliseconds});
3695+
if (success) {
3696+
passed++;
3697+
} else {
3698+
failed++;
3699+
if (stopOnFailure) stopped = true;
3700+
}
3701+
} catch (e) {
3702+
sw.stop();
3703+
steps.add({'tool': toolName, 'success': false, 'time_ms': sw.elapsedMilliseconds, 'error': e.toString()});
3704+
failed++;
3705+
if (stopOnFailure) stopped = true;
3706+
}
3707+
}
3708+
3709+
return MapEntry(sid, {
3710+
'platform': platform,
3711+
'steps': steps,
3712+
'passed': passed,
3713+
'failed': failed,
3714+
});
3715+
});
3716+
3717+
final entries = await Future.wait(futures);
3718+
_activeSessionId = savedSessionId;
3719+
3720+
final results = Map.fromEntries(entries);
3721+
final allPassed = results.values.where((r) => (r['failed'] as int) == 0).length;
3722+
final someFailed = results.values.where((r) => (r['failed'] as int) > 0).length;
3723+
3724+
return {
3725+
'platforms_tested': sessionIds.length,
3726+
'results': results,
3727+
'summary': {
3728+
'total_platforms': sessionIds.length,
3729+
'all_passed': allPassed,
3730+
'some_failed': someFailed,
3731+
},
3732+
};
3733+
}
3734+
3735+
if (name == 'compare_platforms') {
3736+
final sessionIds = (args['session_ids'] as List<dynamic>?)?.cast<String>() ?? _sessions.keys.toList();
3737+
3738+
// Take snapshots from all platforms in parallel
3739+
final futures = sessionIds.map((sid) async {
3740+
try {
3741+
final c = _clients[sid];
3742+
if (c == null) return MapEntry(sid, <String, dynamic>{'error': 'Not connected'});
3743+
if (c is FlutterSkillClient) {
3744+
final structured = await c.getInteractiveElementsStructured();
3745+
final elements = (structured is Map && structured['elements'] is List)
3746+
? (structured['elements'] as List)
3747+
: <dynamic>[];
3748+
final elementKeys = <String>{};
3749+
for (final el in elements) {
3750+
if (el is Map) {
3751+
final type = el['type'] as String? ?? '';
3752+
final text = el['text'] as String? ?? el['label'] as String? ?? '';
3753+
elementKeys.add('$type:$text');
3754+
}
3755+
}
3756+
return MapEntry(sid, <String, dynamic>{
3757+
'platform': _sessions[sid]?.deviceId ?? 'unknown',
3758+
'element_count': elements.length,
3759+
'elements': elementKeys.toList(),
3760+
});
3761+
}
3762+
return MapEntry(sid, <String, dynamic>{'error': 'Not a Flutter client'});
3763+
} catch (e) {
3764+
return MapEntry(sid, <String, dynamic>{'error': e.toString()});
3765+
}
3766+
});
3767+
3768+
final entries = await Future.wait(futures);
3769+
final platformData = Map.fromEntries(entries);
3770+
3771+
// Find all unique element keys across platforms
3772+
final allElements = <String>{};
3773+
final platformElements = <String, Set<String>>{};
3774+
for (final entry in platformData.entries) {
3775+
if (entry.value.containsKey('elements')) {
3776+
final elems = (entry.value['elements'] as List).cast<String>().toSet();
3777+
platformElements[entry.key] = elems;
3778+
allElements.addAll(elems);
3779+
}
3780+
}
3781+
3782+
// Build presence matrix and find inconsistencies
3783+
final inconsistencies = <Map<String, dynamic>>[];
3784+
final presenceMatrix = <String, Map<String, bool>>{};
3785+
for (final element in allElements) {
3786+
final presence = <String, bool>{};
3787+
for (final sid in platformElements.keys) {
3788+
presence[sid] = platformElements[sid]!.contains(element);
3789+
}
3790+
presenceMatrix[element] = presence;
3791+
// If not present on all platforms, it's an inconsistency
3792+
if (presence.values.any((v) => !v)) {
3793+
inconsistencies.add({
3794+
'element': element,
3795+
'present_on': presence.entries.where((e) => e.value).map((e) => e.key).toList(),
3796+
'missing_on': presence.entries.where((e) => !e.value).map((e) => e.key).toList(),
3797+
});
3798+
}
3799+
}
3800+
3801+
return {
3802+
'platforms': platformData,
3803+
'total_unique_elements': allElements.length,
3804+
'inconsistencies': inconsistencies,
3805+
'consistent': inconsistencies.isEmpty,
3806+
};
3807+
}
3808+
35503809
// Auth inject session
35513810
if (name == 'auth_inject_session') {
35523811
final token = args['token'] as String;

0 commit comments

Comments
 (0)