self_test_bridge 2.3.1
self_test_bridge: ^2.3.1 copied to clipboard
The socket and the report server for self_test: drives a running Flutter app from outside it, and serves each run as a local web page.
self_test_bridge #
WebSocket bridge that enables MCP server communication with Flutter apps using the self_test framework.
Overview #
This package provides the Flutter-side infrastructure for AI-powered testing:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ AI Agent │ MCP │ MCP Server │ WS │ Flutter App │
│ (Claude) │◄──────►│ (TypeScript) │◄──────►│ (self_test) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Installation #
Add to your pubspec.yaml:
dependencies:
self_test: ^2.0.0
self_test_bridge: ^2.0.0
Both, not just this one: the bridge is the socket and the report server,
self_test is the binding, the root widget and the recording store.
Quick Start #
1. Start the Bridge #
In your main.dart:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:self_test/self_test.dart';
import 'package:self_test_bridge/self_test_bridge.dart';
final navigatorKey = GlobalKey<NavigatorState>();
final bridgeNavigator = NavigatorStateBridgeNavigator(navigatorKey);
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Start bridge in debug mode only
if (kDebugMode) {
final bridge = SelfTestBridge(
navigator: bridgeNavigator, // How the bridge navigates
port: 9999, // WebSocket port
);
await bridge.start();
}
// Enable self-test mode
SelfTestManager().setSelfTestModeActive(kDebugMode);
runApp(const MyApp());
}
2. Wrap Your App #
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SelfTestRoot(
enableAutoDetection: true, // Auto-detect interactive widgets
child: ScreenshotBoundary(
child: MaterialApp(
navigatorKey: navigatorKey,
// Optional: lets the bridge see the whole route stack.
navigatorObservers: [bridgeNavigator.observer],
routes: {'/': (_) => const HomeScreen()},
),
),
);
}
}
Using a routing package #
BridgeNavigator is a four-member interface, so any router can drive the
bridge. For go_router:
class GoRouterBridgeNavigator implements BridgeNavigator {
GoRouterBridgeNavigator(this.router);
final GoRouter router;
@override
Future<void> goTo(String location, {bool replace = false}) async =>
replace ? router.go(location) : router.push(location);
@override
Future<void> goBack() async => router.pop();
@override
bool get canGoBack => router.canPop();
@override
String? get currentLocation =>
router.routerDelegate.currentConfiguration.uri.toString();
@override
List<Map<String, dynamic>> describeRoutes() => const [];
}
With no navigator configured, the navigation commands return an error rather than reporting a success for a move that never happened.
3. Add Semantic Labels #
For stable test IDs, add Semantics labels to your widgets:
// Button with stable ID
Semantics(
label: 'login_button',
child: ElevatedButton(
onPressed: handleLogin,
child: Text('Login'),
),
)
// Text input with stable ID
Semantics(
label: 'email_input',
child: TextField(
controller: emailController,
decoration: InputDecoration(labelText: 'Email'),
),
)
4. Configure MCP Server #
Add to your Claude Code settings:
{
"mcpServers": {
"flutter-self-test": {
"command": "node",
"args": ["/path/to/self_test_mcp/dist/index.js"],
"env": {
"FLUTTER_APP_HOST": "localhost",
"FLUTTER_APP_PORT": "9999"
}
}
}
}
How It Works #
Bridge Protocol #
The bridge uses a simple JSON-RPC over WebSocket:
Request (MCP → Flutter):
{
"id": 1,
"command": "tap",
"params": { "widgetId": "login_button" }
}
Response (Flutter → MCP):
{
"id": 1,
"result": { "success": true }
}
Supported Commands #
| Command | Description | Parameters |
|---|---|---|
getSnapshot |
Get current widget tree | - |
getWidgetCatalog |
Get all detected widgets | - |
getFlowGraph |
Get navigation structure | - |
getScreens |
List discovered screens | - |
getCurrentState |
Get app state | - |
tap |
Tap a widget | widgetId |
enterText |
Type into input | widgetId, text, submit? |
navigate |
Go to route | route |
scroll |
Scroll in container | widgetId?, direction, amount? |
screenshot |
Capture screen | name? |
assertWidget |
Check widget state | widgetId, assertion, expectedText? |
runScenario |
Run test scenario | name, steps[] |
waitFor |
Wait for condition | condition, widgetId?, timeoutMs? |
Widget Auto-Detection #
When enableAutoDetection: true, the bridge automatically detects:
ElevatedButton,TextButton,OutlinedButtonIconButton,FloatingActionButtonGestureDetector(withonTap)InkWellTextField,TextFormField
The report page #
The bridge already holds a port, so the same port serves a page: every run the app recorded, with its steps, its timings and its screenshots, and the scripts it has stored, which can be run and edited from the browser.
SelfTestManager().useRunReportStore(
FileRunReportStore('${dir.path}/self_test_reports'),
);
Then open http://127.0.0.1:9999/report?token=<your token>.
The app serves it rather than writing files to your machine because the screenshots live inside the app's sandbox: on a simulator they can be copied out, on a real device they cannot be reached at all.
| Route | Answers |
|---|---|
GET /report |
the page: one file of HTML, CSS and plain JavaScript, no build step, nothing fetched from the network |
GET /report/api/status |
whether a run is under way, which step it is on, and how the next run will photograph itself |
POST /report/api/capture |
sets that capture mode |
GET /report/api/runs |
every run kept, newest first |
GET /report/api/runs/<id> |
one run, step by step |
DELETE /report/api/runs/<id> |
forgets a run and its pictures |
GET /report/shot/<runId>/<name> |
one screenshot |
GET /report/export/<runId> |
one run as a single HTML file, screenshots inlined, linking to nothing |
GET /report/api/vocabulary |
what a step can be: the actions, their other spellings, whether each needs a target, what its value means, and which family it is in |
GET /report/api/screen |
what is on screen now, each with the strongest locator that finds it |
POST /report/api/resolve |
what a locator finds, before a run depends on it |
GET /report/api/scripts |
the stored scripts |
GET /report/api/scripts/<id> |
one script, step by step |
POST /report/api/scripts/<id>/run |
starts it, and answers at once: a scenario takes minutes |
POST /report/api/scripts/stop |
cancels the run under way |
POST /report/api/scripts/<id>/rename |
renames it |
DELETE /report/api/scripts/<id> |
deletes it |
GET /report/api/scripts/<id>/export |
the script as a file that carries no ids from this app |
POST /report/api/scripts/import |
takes one back, checked by the same rules as a suite installed over the socket |
POST /report/api/scripts/<id>/steps |
adds a step at at |
POST /report/api/scripts/<id>/steps/<n> |
changes step n |
DELETE /report/api/scripts/<id>/steps/<n> |
removes step n and closes the gap |
POST /report/api/scripts/<id>/steps/<n>/move |
moves step n to to |
Every one of them runs after the same token check as the socket, on the same loopback address. This is another way in, not more reach.
Imported scripts may use a compound mockChannel step when one plugin speaks
different channels on different platforms. Put a channels list in the step
params, with the same fields a single mock uses:
{
"action": "mockChannel",
"params": {
"channels": [
{
"channel": "dev.flutter.pigeon.image_picker_ios.ImagePickerApi.pickImage",
"codec": "message",
"response": "self_test:kept/self_test_seed_photo.jpg"
},
{
"channel": "dev.flutter.pigeon.image_picker_android.ImagePickerApi.pickImages",
"codec": "message",
"response": ["self_test:kept/self_test_seed_photo.jpg"]
}
]
}
}
The app installs every entry before the next step. iOS consumes the first one,
Android consumes the second one, and old scripts that name a single channel
still import unchanged.
Reading a run without reading it #
A script is a long column of nearly identical rows, so the page colours the
one word that differs. The family comes from the app, over
/report/api/vocabulary, which is why an action added to StepVocabulary
arrives already sorted instead of falling into the colour everything else has.
| Family | Actions | Drawn |
|---|---|---|
act |
trigger, doubleTap, longPress |
blue |
input |
enterText, submit |
teal |
flow |
scroll, goBack, wait |
grey, so getting around recedes |
mock |
mockChannel |
orange |
check |
assertText, assertExists, assertAbsent, assertAbove |
violet, because they are what the script is for |
The verdict is on the edge of the row rather than only in its tag, so a list of runs shows where the failures are before any of them is read: green passed, red failed, amber repaired, and a step that never ran is dimmed. What is being looked at outranks what it says, so a selected row keeps the selection colour.
An exported run is drawn the same way, from the same list, so a report opened a week later on another machine reads the way the page did.
AI Testing Workflow #
1. AI Gets Widget Snapshot #
AI → flutter_snapshot
Flutter → { currentScreen: "login", widgets: [...] }
2. AI Interacts #
AI → flutter_type { widgetId: "email_input", text: "test@example.com" }
AI → flutter_type { widgetId: "password_input", text: "password123" }
AI → flutter_tap { widgetId: "submit_button" }
3. AI Verifies #
AI → flutter_assert { widgetId: "welcome_message", assertion: "exists" }
AI → flutter_screenshot { name: "login_success" }
Best Practices #
1. Use Semantic Labels #
Always prefer Semantics labels over widget keys:
// ✅ Good - stable, accessible
Semantics(
label: 'submit_button',
child: ElevatedButton(...),
)
// ⚠️ Okay - stable but not accessible
ElevatedButton(
key: const ValueKey('submit_button'),
...
)
// ❌ Bad - unstable ID generated
ElevatedButton(...) // → elevatedbutton_12345_UNSTABLE
2. Group by Screen #
Widget IDs are automatically grouped by screen. Use consistent naming:
login_email_input
login_password_input
login_submit_button
settings_notifications_toggle
settings_dark_mode_toggle
3. Handle Loading States #
For async operations, use waitFor:
// In test scenario
TestStep.tap('submit_button'),
TestStep.custom('waitFor', {
condition: 'widget_exists',
widgetId: 'success_message'
}),
Security #
The bridge only runs in debug mode by default. Never include it in release builds:
if (kDebugMode) {
await bridge.start();
}
Troubleshooting #
"Connection refused" #
- Make sure the Flutter app is running
- Check the port isn't blocked by firewall
- Verify
bridge.start()was called
"Widget not found" #
- Run
flutter_snapshotto see available widgets - Check if widget has a semantic label
- Wait for animations with
flutter_wait
"Unstable ID warning" #
Add a Semantics label to the widget:
Semantics(
label: 'my_widget_id',
child: MyWidget(...),
)