flutter_wright
Drive a running Flutter app over HTTP — snapshot the UI, then tap, type, and scroll it. Built for AI agents and automated end-to-end testing, in the spirit of Playwright.
The SDK runs a tiny control server inside your app. It is off by default and only binds a socket when you explicitly enable it, so the call is safe to leave in production code — release builds become a no-op.
Companion to the
flutter-wrightClaude Code skill. But any HTTP client works just as well — curl, Postman, or your own script.
Install
dev_dependencies:
flutter_wright: ^0.9.0
Or run flutter pub add dev:flutter_wright.
Quick start
import 'package:flutter_wright/flutter_wright.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterWright.start(enabled: kDebugMode); // binds in debug, no-op in release
runApp(const MyApp());
}
That is the whole setup. The control server listens on 127.0.0.1:9123, and /snapshot, /tap, /type, and /scroll work right away.
How it works: snapshot first
GET /snapshotreturns a semantic tree of the current screen. Every actionable node carries a[ref=sN]handle.- Find the
refof the element you want. - Send it to an action endpoint, e.g.
POST /tap {"ref":"s10"}.
Refs are temporary handles. They change the moment the screen does — navigation, list refresh, rebuild. Take a fresh snapshot before each interaction, and never reuse a ref across screens.
adb forward tcp:9123 tcp:9123 # Android: reach the device from your laptop
curl localhost:9123/snapshot # see the tree and its refs
curl -X POST localhost:9123/tap -d '{"ref":"s10"}' # act on one
HTTP API
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /snapshot |
— | semantic tree with [ref=sN] |
| POST | /tap |
{"ref":"sN"} |
latest snapshot |
| POST | /type |
{"ref":"sN","text":"…"} |
latest snapshot |
| POST | /scroll |
{"ref":"sN", …} |
latest snapshot |
| POST | /long_press |
{"ref":"sN"} |
latest snapshot |
| GET | /wait_for |
?text=… |
latest snapshot |
| POST | /navigate |
{"route":"/x","args":{…}} |
{"route":"/x"} |
| POST | /reset |
{} |
{"ok":true} |
| GET | /routes |
— | known route names |
| GET | /screenshot |
— | PNG bytes |
| GET | /health |
— | status JSON |
Action endpoints return the latest snapshot in their response, so you rarely need a separate /snapshot call after each step. Errors always come back as {"ok":false,"error":"…"}.
Navigation (optional)
/snapshot and the action endpoints work with no extra wiring. To also expose /navigate, /reset, and /routes, tell the SDK how to move between screens.
Navigator 1.0 named routes — share the key:
MaterialApp(navigatorKey: FlutterWright.navigatorKey, /* … */)
GoRouter or GetX — pass an adapter, and the SDK just calls your closures:
await FlutterWright.start(
enabled: kDebugMode,
navigationAdapter: CallbackNavigationAdapter(
onNavigate: (route, args, _) => Get.toNamed(route, arguments: args), // GoRouter: router.go(route, extra: args)
onReset: () => Get.until((route) => route.isFirst),
routesProvider: () => GetPages.getPages.map((p) => p.name),
),
);
Screenshots (optional)
/screenshot only needs setup for Flutter-rendered captures. Wrap your app once:
runApp(FlutterWrightRoot(child: const MyApp()));
Prefer to skip it? Capture externally with adb exec-out screencap and set screenshotMode: ScreenshotMode.external.
WebView H5 (optional)
If your app embeds a WebView, its H5 content is opaque to the snapshot by default. Register a one-line "run this JS" closure and the H5 nodes join the tree like native ones — drivable by the same tap, type, and scroll:
FlutterWright.registerWebView((js) => controller.evaluateJavascript(source: js));
No unregister needed; weak references clean themselves up. Skip it and WebViews simply stay opaque — nothing else changes.
Auth (optional)
Pass a token to require it on every endpoint except /health:
await FlutterWright.start(
enabled: kDebugMode,
token: const String.fromEnvironment('FW_TOKEN'), // read from env, never commit
);
Then send it as a header: curl … -H "X-FW-Token: <token>". Without a token, the server is protected only by its loopback binding.
Why "off by default"?
start(enabled: false) does nothing — no socket, no attack surface. You decide when to turn it on: kDebugMode for debug builds, or your own flag such as AppEnv.isTestBuild for QA release builds. That is what makes it safe to ship the call in production code.
License
MIT
Libraries
- flutter_wright
- Debug-only in-app HTTP server for Flutter apps. Designed to be driven by
the matching
flutter-wrightClaude Code skill, but the HTTP API is plain JSON over127.0.0.1so any client works.