flutter_wright 0.9.0
flutter_wright: ^0.9.0 copied to clipboard
Playwright-style control of a running Flutter app — snapshot, tap, type, assert — for AI-driven and automated end-to-end testing. Debug-only in-app HTTP server.
flutter_wright #
作为 flutter-wright monorepo 的一部分发布。
嵌进 Flutter app 的 HTTP 控制服务,默认关闭(enabled: false),仅在宿主显式传
enabled: true 时绑定——是否启用由宿主决定(常见 enabled: kDebugMode,提测 release
包用 enabled: AppEnv.isTestBuild)。给配套的
flutter-wright Claude Code skill 用,
但任何能讲 HTTP 的客户端(curl、Postman、你自己的脚本)都能调。
enabled默认false;不传或传enabled: kDebugMode时,release 下start()是 no-op、不绑 socket。 启用与否由调用方控制(非 SDK 自动识别构建类型);正式包让判断为false即彻底关闭。可以放心留在生产代码里。 release 提测包可用enabled: AppEnv.isTestBuild之类自有判断启用。
安装 #
推荐放 dev_dependencies(配合独立 debug 入口,生产 lib/ 零 SDK 引用、release 零残留):
dev_dependencies:
flutter_wright: ^0.9.0
或一行:flutter pub add dev:flutter_wright。仍想用 git/monorepo 依赖,见 集成指南。
图省事也可放
dependencies直接在lib/main.dart里 import —— 传enabled: kDebugMode时 release 下为 no-op,但 SDK 仍被编进生产包。两种范式详见docs/integration-guide.md;packages/example已按 dev_dependencies 范式组织。
集成(3 行,图省事路径) #
import 'package:flutter_wright/flutter_wright.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterWright.start(enabled: kDebugMode); // 1. 启动控制 server(debug 为 true;release 为 false → no-op)
runApp(FlutterWrightRoot(child: const MyApp())); // 2. 让 /screenshot 可用
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: FlutterWright.navigatorKey, // 3.(可选)把 navigator 让出来
onGenerateRoute: appRouter,
);
}
}
// 可选:传入路由名列表,让 GET /routes 可发现
await FlutterWright.start(enabled: kDebugMode, routes: appRoutes.keys);
第 3 步是可选的:仅当需要
/navigate、/reset、/routes时才让出navigatorKey(或给start()传navigationAdapter)。start()不传navigatorKey/navigationAdapter时,这三个端点返回501;交互层(/snapshot、/tap、/type等)照常可用。
不同路由架构(GoRouter / GetX) #
第 3 步的 navigatorKey 只适用 Navigator 1.0 命名路由。其它栈给 start() 传 CallbackNavigationAdapter,SDK 只回调你的闭包:
// GoRouter(附 routesProvider,让 GET /routes 可发现)
await FlutterWright.start(
enabled: kDebugMode, // debug 为 true;release 为 false → no-op
navigationAdapter: CallbackNavigationAdapter(
onNavigate: (route, args, _) => router.go(route, extra: args),
onReset: () => router.go('/'),
routesProvider: () => goRouterPaths(router.configuration.routes),
),
);
// GetX
await FlutterWright.start(
enabled: kDebugMode,
navigationAdapter: CallbackNavigationAdapter(
onNavigate: (route, args, _) => Get.toNamed(route, arguments: args),
onReset: () => Get.until((r) => r.isFirst),
routesProvider: () => getPages.map((p) => p.name),
),
);
HTTP API #
| 方法 | 路径 | Body | Response |
|---|---|---|---|
| GET | /health | — | {"ok":true,"service":"flutter_wright_sdk","version":"0.9.0","name":null,"package":null} |
| GET | /routes | — | {"ok":true,"routes":[...]} |
| GET | /snapshot | — | {"ok":true,"snapshot":...}(带 [ref=sN]) |
| GET | /wait_for | ?text=... 等查询参数 |
{"ok":true,...} |
| POST | /navigate | {"route":"/x","args":{...},"popUntilRoot":true} |
{"ok":true,"route":"/x"} |
| POST | /reset | {} |
{"ok":true} |
| POST | /tap | {"element":"...","ref":"sN"} |
{"ok":true,"snapshot":...} |
| POST | /long_press | {"element":"...","ref":"sN"} |
{"ok":true,"snapshot":...} |
| POST | /scroll | {"element":"...","ref":"sN",...} |
{"ok":true,"snapshot":...} |
| POST | /type | {"element":"...","ref":"sN","text":"..."} |
{"ok":true,"snapshot":...} |
| GET | /screenshot | — | image/png 字节(mode=flutter 时) |
动作端点(/tap、/long_press、/scroll、/type,以及 /wait_for 等)请求体含 element(可读标签,便于审计)+ ref(目标 ref),成功响应自动回吐最新 snapshot,省去再单独调一次 /snapshot。
/health 响应五键恒为 ok / service(值恒为 flutter_wright_sdk)/ version / name / package;其中 name、package 由 start() 的 appName、package 参数传入,用于多目标探活(没传则为 null)。/health 免鉴权。
所有错误响应统一格式:{"ok":false,"error":"..."}。
snapshot-first 工作法 #
交互层(/snapshot /tap /long_press /scroll /type /wait_for)为 0.7.0 新增。先 GET /snapshot 拿到带 [ref=sN] 标记的语义树,从中挑出目标元素的 ref,再把 ref 喂给动作端点。ref 是临时句柄,页面一变(导航、列表刷新、重建)即失效——每次交互前重新取 snapshot,不要跨页面复用旧 ref。
鉴权(可选) #
给 start() 传 token: 即开启鉴权:除 GET /health 外所有端点都校验 X-FW-Token 请求头,缺失或不匹配返回 401。token 为空/null 时不鉴权(此时仅靠 loopback 绑定保护)。
token 只从环境变量 / 本地配置读取,不要写进仓库:
await FlutterWright.start(
enabled: kDebugMode,
token: const String.fromEnvironment('FW_TOKEN'),
);
调用时带上头:
curl http://localhost:9123/snapshot -H "X-FW-Token: <token>"
从笔记本连(Android 设备) #
adb forward tcp:9123 tcp:9123
curl http://localhost:9123/health
curl -X POST http://localhost:9123/navigate \
-H 'content-type: application/json' \
-H "X-FW-Token: <token>" \
-d '{"route":"/order/detail","args":{"id":"ORD-001"}}'
配置 #
await FlutterWright.start(
enabled: kDebugMode, // debug 为 true;release 为 false → no-op
// 以下三个是 start() 的顶层参数,不在 FlutterWrightConfig 内:
token: const String.fromEnvironment('FW_TOKEN'), // 从 env 读,不进仓库
appName: 'MyApp', // 写进 /health 的 name
package: 'com.example.myapp', // 写进 /health 的 package
config: const FlutterWrightConfig(
host: '127.0.0.1', // 真实 app 里别用 0.0.0.0
port: 9123,
autoStart: true,
screenshotMode: ScreenshotMode.flutter, // 或 .external(让 adb 截)
maxBodyBytes: 1024 * 1024,
),
);
选填:WebView 内 H5 透明接入 #
若 app 内嵌 WebView,默认其 H5 节点不进语义树。注册一个「跑 JS」闭包即可让 H5 像 Flutter 节点一样进 snapshot、被 tap/type/scroll 驱动(skill 侧零改):
// 拿到 controller 处 +1 行(flutter_inappwebview 示例):
FlutterWright.registerWebView((js) => controller.evaluateJavascript(source: js));
// webview_flutter:(js) => controller.runJavaScriptReturningResult(js)
无需 unregister(弱引用自清理);未注册则 WebView 仍是不透明节点,其余方法毫发无损。