self_test_bridge 2.3.1 copy "self_test_bridge: ^2.3.1" to clipboard
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, OutlinedButton
  • IconButton, FloatingActionButton
  • GestureDetector (with onTap)
  • InkWell
  • TextField, 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" #

  1. Make sure the Flutter app is running
  2. Check the port isn't blocked by firewall
  3. Verify bridge.start() was called

"Widget not found" #

  1. Run flutter_snapshot to see available widgets
  2. Check if widget has a semantic label
  3. Wait for animations with flutter_wait

"Unstable ID warning" #

Add a Semantics label to the widget:

Semantics(
  label: 'my_widget_id',
  child: MyWidget(...),
)
0
likes
150
points
476
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#testing #flutter #automation #websocket

License

MIT (license)

Dependencies

dio, flutter, self_test, shared_preferences, web_socket_channel

More

Packages that depend on self_test_bridge