arenukvern / flutter-mcp-e2e-harness
Use this skill when writing or running repeatable E2E scenarios for Flutter apps as checked-in Dart with the `flutter_mcp_harness` package (`packages/harness`) — build/launch the app, attach to its VM service, drive widgets (snapshot/tap/enter text) and assert without MCP; also covers the repo showcase launcher (`make showcase`, `--web`, `--stop`) and multi-instance desktop compositions. For interactive assistant-driven debugging through MCP `fmt_*` tools, use `flutter-mcp` instead.
<!-- @FMTMODEPRELUDE -->
Flutter MCP E2E Harness
Golden path for programmatic E2E over the toolkit: packages/harness
(flutter_mcp_harness) owns the whole lifecycle — build the app, launch the
process, attach to its Dart VM service, and only then drive/assert. It speaks
the same ext.mcp.toolkit.* service extensions as the MCP server, as plain
Dart. ADR: decisions/0015_flutter_mcp_harness_extraction.mdx.
The driving layer speaks the universal AutomationDriver contract
(ADR-0038, universal_automation_interface): ToolkitDriver exposes the
observe/act/verify loop (snapshot → semantic AxNode tree with refs and
bounds; perform click/type/key/named-route-navigate/evaluate; screenshot
PNG), so the same agent vocabulary drives Flutter apps, browsers
(CdpDriver), and OS-native targets. IntentDriverRouter additionally
routes an intent's automation hint (transport + verb + locator) onto the
bound driver. Frame pipelines are deliberately NOT a harness dependency —
they are composition-level (showcase/drivers, which also holds the live
end-to-end drives: make drive-flutter / drive-flutter-chrome /drive-web).
App-specific verbs use the invoke tier (ADR-0017): the app's intent
registry IS the action catalog — driver.actions() lists it, andInvokeAction(name, args) dispatches to a registered entry with
schema-validated arguments (custom IntentAutomationAction.custom hints
route the same way). Web surfaces without a VM service (Jaspr, plain JS)
compose identically over CdpDriver via the window.__mcpActions page
registry.
| Need | Use |
|---|---|
| Repeatable scenario as code, CI-friendly exit codes | this skill (flutter_mcp_harness) |
| One-off interactive debugging from chat/editor | flutter-mcp (MCP fmt_* tools) |
| Browser/WebMCP dogfood of the web showcase | flutter-mcp-toolkit-maintain-web |
| Live end-to-end showcase drives (both tiers) | showcase/drivers (make drive-flutter / drive-web) |
Launch paths (pick by ownership)
The #1 rule: at most one owning session per app. A secondflutter run/flutter attach on a session someone else owns is the
ADR-0014 failure mode (the second attach can kill the first).
| Target | When | Notes |
|---|---|---|
FlutterRunTarget | You want the flutter tool to own build + hot reload (r/R/q via stdin). The showcase launch path. | device (macos, chrome, …), extraArgs, onLine tee for long cold builds; VM URI scraped from the tool's own announcement. |
BinaryAppTarget / MacosAppTarget / WindowsAppTarget | Fresh full flutter build <os> --debug → launch the binary directly. No compile channel afterwards. | Pass launch(build: false) to reuse an existing binary. Never opens an owning session. |
External runner (e.g. oka dev) owns the app | Construct LaunchedApp directly around the owning process and read the VM URI from the runner-session contract, not stdout. | This package never imports the runner; callers wire it (LaunchedApp(name:, process:, stdout:, vmUri:)). |
FlutterRunTarget.launch(build:) ignores the flag — flutter run always
builds. A failed bring-up reaps the session (SIGTERM → child-reaping pkill →
SIGKILL) before rethrowing.
Scenario anatomy
final scenario = Scenario('checkout E2E', steps: [
('launch app', (context) async {
final app = await MacosAppTarget(projectDir: appDir, binaryPath: binary)
.launch(build: build);
launched = app; // for finally-stop
await app.stdout.waitFor('app ready'); // no sleeps — wait on logs
context.bag['app'] = app; // hand values between steps
}),
('drive + assert', (context) async {
final driver = WidgetDriver(await context.take<LaunchedApp>('app').vm());
final ref = await driver.findRef('checkout');
if (ref == null) return context.report.fail('no checkout button');
await driver.tap(ref);
final total = await driver.findValue((v) => v.startsWith(r'$'));
total != null
? context.report.pass('total rendered: $total')
: context.report.fail('no total in snapshot');
}),
]);
var passed = false;
try {
passed = await scenario.run();
scenario.report.printSummary();
} finally {
await launched?.stop(); // cleanup always runs
}
exit(passed ? 0 : 1);Steps run in order; first failure (thrown or
report.fail) aborts the
rest — cleanup still happens in the caller'sfinally.HarnessContext.bagpasses values between steps;take<T>(key)reads them.retry(action)polls until non-null (for app-side readiness races).- Full two-instance composition root:
packages/harness/example/desktop_pair.dart.
Driving widgets (WidgetDriver)
snapshot()→(label, ref)pairs;findRef(needle)is case-insensitive
substring + retry until timeout.tap(ref),tapUntil(ref, until: …)(re-tap until a predicate fires),enterText(ref, text),scroll(direction:, distance:)— off-screen
semantics only exist once scrolled into view.findValue(predicate)reads a node's selectable-textvalue— the
deterministic way to read app-rendered data (clipboards and icon-only copy
buttons are not).Cold-attach race: the app may not have registered the toolkit yet when you
attach. The driver answers RPC error-32601with an empty snapshot sofindRef's retry loop rides it out instead of aborting — do not treat one
empty snapshot as proof the UI is empty.VmClientextras:extensionNames()(discoverability),evaluate(expr),hotReload(). RawreloadSourcescannot compile without an owningflutter run/attachsession — treattrueas "asked", not "recompiled".
Showcase launcher (packages/harness/tool/showcase.dart)
make showcase # macOS showcase, interactive foreground (r/R/q relayed)
make web-showcase # Chrome + WebMCP flags; WEB_PORT/VM_HOST_PORT/FLIGHT… env
make showcase-stop # kill stray sessions, free VM port 8181 (idempotent)--web --detach(ormake-lessdart run packages/harness/tool/showcase.dart --web --detach)
spawns headless-ish, waits for thews://VM URI in.showcase/web_app.log,
printsWS_URI=…, exits 0 — the CI-friendly variant.Artifacts under
.showcase/:flutter_app.log/web_app.log(teed
output) andflutter.pid/web_flutter.pidpid files.IntentCall doors (second terminal, against the running showcase):
`dart run packages/harness/tool/intentcall_session.dart
[demo|serve-link|serve-debug]` — resolves the VM service URI from the
showcase log (--vm-service-uripins one) and the IntentCall CLI fromINTENTCALL_ROOT, sibling checkouts, orPATH.Interactive mode tees every line to the log (which
make exec-sweepgreps)
and tears the session down on exit or Ctrl-C.- WS URI for follow-up calls:
grep -Eo 'ws://127\.0\.0\.1:[0-9]+/[A-Za-z0-9_=-]+/ws' .showcase/flutter_app.log | tail -1.
Hard boundaries
No new toolkit verbs.
ToolkitExtensionsmirrorsmcp_toolkit/lib/src/toolkits/interaction_toolkit.dart; keep the lists in
lockstep — drift is caught by extension calls failing at runtime today
(shared-constants follow-up will make it a compile error).- No owned compile sessions in
BinaryAppTarget— full fresh builds only. No declarative scenario documents (YAML runners) — the external
flutter_harnessexperiment (ADR-0012) is retired (ADR-0017).
Composition roots are consuming-project code (seeexample/).Android/iOS device bring-up is delegated to the owning dev session
(oka_harness, ADR-0014) — not this package.
Verify
cd packages/harness && dart test # unit tests (canned envelopes, fake flutter)
dart run example/desktop_pair.dart --skip-buildRelated
flutter-mcp— the interactive MCP loop over the same extensionsflutter-mcp-automation-chain— cross-tier wiring for your own app (surface picker, oka integration, runner sessions)flutter-mcp-toolkit-maintain-macos/-maintain-web— showcase + platform lanespackages/harness/README.md, ADR-0014 (runner delegation), ADR-0015 (extraction)