diff --git a/README.md b/README.md index 5b6b0c0..6a96d44 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,8 @@ actions, screenshots, semantic Snapshots, logs, run reports, and timeline views. The goal is to make a UI bug report readable by humans, CI, and AI coding agents without relying on vague manual reproduction notes. -Flutter Pilot builds above `mcp_flutter`, which provides the Flutter runtime -bridge for interaction and inspection. Thanks to the `mcp_flutter` project for -making Flutter UI state and runtime operations available through a toolable -interface. +Flutter Pilot drives debug Runtime Targets through `pilot_runtime`, the +Flutter Pilot-owned runtime package for app-side interaction and inspection. ## What It Does @@ -102,9 +100,9 @@ Install the Flutter Pilot CLI: dart pub global activate flutter_pilot ``` -Flutter Pilot drives a Flutter app through `mcp_flutter`. The target app must -expose the MCP Toolkit runtime extension before `flutter_pilot test` can -interact with it. +Flutter Pilot drives a Flutter app through `pilot_runtime`. The target app must +initialize the Pilot Runtime binding before `flutter_pilot test` can interact +with it. From the Target App Package, initialize the safe app-side setup: @@ -112,18 +110,18 @@ From the Target App Package, initialize the safe app-side setup: flutter_pilot init ``` -`init` installs the MCP Toolkit runtime dependency when it is missing. It does -not edit `lib/main.dart`; when `bootstrapFlutter` is missing, it prints the -import and `runApp` wrapper to add manually: +`init` installs the `pilot_runtime` dependency when it is missing. It does not +edit `lib/main.dart`; when `PilotRuntimeBinding.ensureInitialized()` is +missing, it prints the import and binding call to add manually: ```dart import 'package:flutter/material.dart'; -import 'package:mcp_toolkit/mcp_toolkit.dart'; +import 'package:pilot_runtime/pilot_runtime.dart'; -Future main() async { - await MCPToolkitBinding.instance.bootstrapFlutter( - runApp: () => runApp(const MyApp()), - ); +void main() { + WidgetsFlutterBinding.ensureInitialized(); + PilotRuntimeBinding.ensureInitialized(); + runApp(const MyApp()); } ``` @@ -288,6 +286,5 @@ consistent. ## Scope -Flutter Pilot focuses on reproducible Flutter UI debugging artifacts. It does -not replace `mcp_flutter`, and it is not trying to become a broad visual -regression platform in the first version. +Flutter Pilot focuses on reproducible Flutter UI debugging artifacts. It is not +trying to become a broad visual regression platform in the first version. diff --git a/README_zh.md b/README_zh.md index 74ed4b0..785b2d8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,12 +9,11 @@ Flutter UI 操作路径描述成可提交、可分享、可重复执行的 YAML 调试所需的上下文整理成适合开发者、CI 和 AI 编码代理阅读的产物。 > 项目状态:Flutter Pilot 仍在开发中。当前实现重点是 Dart CLI、Scenario -> YAML 解析与校验、报告生成和命令外壳;通过 `mcp_flutter` 实际驱动 Flutter -> UI 的执行能力还在建设中。 +> YAML 解析与校验、报告生成和命令外壳;通过 `pilot_runtime` 驱动 Flutter +> UI 的执行能力正在建设中。 -Flutter Pilot 构建在 `mcp_flutter` 之上。`mcp_flutter` 负责提供 Flutter -运行时交互和检查能力,Flutter Pilot 则在其上增加 Scenario DSL、调试产物收集、 -运行报告和后续 diff 能力。 +Flutter Pilot 通过 `pilot_runtime` 驱动 Flutter debug Runtime Target。 +`pilot_runtime` 是 Flutter Pilot 自有的运行时包,负责应用侧交互和检查能力。 ## 它能做什么 @@ -95,8 +94,8 @@ HTML timeline report 会把同一次 UI 旅程转换成可视化审查界面。 dart pub global activate flutter_pilot ``` -Flutter Pilot 通过 `mcp_flutter` 驱动 Flutter 应用。目标应用需要先暴露 MCP -Toolkit 运行时扩展,`flutter_pilot test` 才能和它交互。 +Flutter Pilot 通过 `pilot_runtime` 驱动 Flutter 应用。目标应用需要先初始化 +Pilot Runtime binding,`flutter_pilot test` 才能和它交互。 在 Target App Package 中初始化安全的应用侧配置: @@ -104,17 +103,18 @@ Toolkit 运行时扩展,`flutter_pilot test` 才能和它交互。 flutter_pilot init ``` -`init` 会在缺少 MCP Toolkit 运行时依赖时安装它。它不会修改 `lib/main.dart`; -如果缺少 `bootstrapFlutter`,它会打印需要手动添加的 import 和 `runApp` 包裹代码: +`init` 会在缺少 `pilot_runtime` 依赖时安装它。它不会修改 `lib/main.dart`; +如果缺少 `PilotRuntimeBinding.ensureInitialized()`,它会打印需要手动添加的 +import 和 binding 调用: ```dart import 'package:flutter/material.dart'; -import 'package:mcp_toolkit/mcp_toolkit.dart'; +import 'package:pilot_runtime/pilot_runtime.dart'; -Future main() async { - await MCPToolkitBinding.instance.bootstrapFlutter( - runApp: () => runApp(const MyApp()), - ); +void main() { + WidgetsFlutterBinding.ensureInitialized(); + PilotRuntimeBinding.ensureInitialized(); + runApp(const MyApp()); } ``` @@ -268,5 +268,4 @@ dart test ## 范围 -Flutter Pilot 聚焦可复现的 Flutter UI 调试产物。它不会替代 `mcp_flutter`, -第一版也不会扩展成通用的视觉回归平台。 +Flutter Pilot 聚焦可复现的 Flutter UI 调试产物。第一版不会扩展成通用的视觉回归平台。 diff --git a/docs-internal/adr/0001-use-dart-cli-with-yaml-scenario-dsl.md b/docs-internal/adr/0001-use-dart-cli-with-yaml-scenario-dsl.md index ae213a1..702870a 100644 --- a/docs-internal/adr/0001-use-dart-cli-with-yaml-scenario-dsl.md +++ b/docs-internal/adr/0001-use-dart-cli-with-yaml-scenario-dsl.md @@ -1,3 +1,3 @@ # Use Dart CLI with YAML Scenario DSL for Flutter Pilot -Flutter Pilot will start as a Dart CLI package with a YAML Scenario DSL, rather than a Flutter app, an `integration_test` wrapper, or a standalone MCP server. A CLI gives humans, CI, and AI agents a stable command surface for validating scenarios, replaying UI paths, and collecting artifacts, while the YAML Scenario format keeps reproductions portable across machines and Runtime Targets. This keeps Flutter Pilot focused on reproducible debugging artifacts above `mcp_flutter`, instead of coupling the first version to a specific UI shell or Flutter test harness. +Flutter Pilot will start as a Dart CLI package with a YAML Scenario DSL, rather than a Flutter app, an `integration_test` wrapper, or a standalone MCP server. A CLI gives humans, CI, and AI agents a stable command surface for validating scenarios, replaying UI paths, and collecting artifacts, while the YAML Scenario format keeps reproductions portable across machines and Runtime Targets. This keeps Flutter Pilot focused on reproducible debugging artifacts through `pilot_runtime`, instead of coupling the first version to a specific UI shell or Flutter test harness. diff --git a/docs-internal/flutter-pilot-prd.md b/docs-internal/flutter-pilot-prd.md index a925745..2e25db8 100644 --- a/docs-internal/flutter-pilot-prd.md +++ b/docs-internal/flutter-pilot-prd.md @@ -4,11 +4,11 @@ Flutter teams and AI coding agents need a reliable way to reproduce UI bugs, capture the runtime context around failures, and verify fixes without manually driving the app each time. Today, a bug report often contains a screenshot, a vague reproduction path, and scattered logs. That is not enough for an AI agent to locate the relevant widget, understand the visible UI state, or prove that a fix changed the right behavior. -The project should provide a deterministic UI replay and diagnostics harness for Flutter apps. It should use `mcp_flutter` as the runtime bridge, then add a scenario DSL, artifact collection, reporting, and diffing layer above it. +The project should provide a deterministic UI replay and diagnostics harness for Flutter apps. It should use `pilot_runtime` as the runtime bridge, then add a scenario DSL, artifact collection, reporting, and diffing layer above it. ## Solution -Flutter Pilot will be a CLI tool that executes YAML scenarios against a Flutter app through `mcp_flutter`. A scenario describes user actions such as tapping, typing, scrolling, waiting for UI state, and capture checkpoints. Runtime connection details are not embedded in the scenario. The `test` command launches the current Target App Package with `flutter run --machine`, shows Target App Launch Progress while waiting for the Runtime Target URI from Flutter's machine output, runs the Scenario with Step progress, and cleans up the launched app process. During a run, Flutter Pilot records step-level artifacts including screenshots, semantic snapshots, widget summaries, logs that include runtime errors when available, timing, and command results. +Flutter Pilot will be a CLI tool that executes YAML scenarios against a Flutter app through `pilot_runtime`. A scenario describes user actions such as tapping, typing, scrolling, waiting for UI state, and capture checkpoints. Runtime connection details are not embedded in the scenario. The `test` command launches the current Target App Package with `flutter run --machine`, shows Target App Launch Progress while waiting for the Runtime Target URI from Flutter's machine output, runs the Scenario with Step progress, and cleans up the launched app process. During a run, Flutter Pilot records step-level artifacts including screenshots, semantic snapshots, widget summaries, logs that include runtime errors when available, timing, and command results. The user-facing execution command is `flutter_pilot test`. With a Scenario file argument it executes that Entry Scenario. With no argument it runs the Target @@ -34,8 +34,8 @@ The result is a reproducible bug report package that can be consumed by humans, 1. As a Flutter developer, I want to describe a UI reproduction path in YAML, so that I can replay the same bug consistently. 2. As a Flutter developer, I want to tap widgets by visible text, so that simple scenarios are easy to write. -3. As a Flutter developer, I want future key-based Finders to remain possible if `mcp_flutter` exposes stable key data, so that scenarios can become more resilient when text changes. -4. As a Flutter developer, I want to tap widgets by semantic node type, so that I can interact with UI elements by the role exposed through `mcp_flutter`. +3. As a Flutter developer, I want future key-based Finders to remain possible if `pilot_runtime` exposes stable key data, so that scenarios can become more resilient when text changes. +4. As a Flutter developer, I want to tap widgets by semantic node type, so that I can interact with UI elements by the role exposed through `pilot_runtime`. 5. As a Flutter developer, I want to combine text and semantic node type Finders in one step, so that I can disambiguate similar widgets. 6. As a Flutter developer, I want combined Finders to always use all constraints without a configurable `match` field, so that Scenario behavior is predictable and the YAML stays compact. 7. As a Flutter developer, I want to type text into fields by text Finder or semantic node type, so that form-based bugs can be reproduced. @@ -70,10 +70,10 @@ The result is a reproducible bug report package that can be consumed by humans, 36. As a QA engineer, I want scenario runs to fail with clear exit codes, so that Flutter Pilot can be used in automation. 37. As a QA engineer, I want artifacts produced even on failed runs, so that CI failures are debuggable. 38. As a QA engineer, I want a human-readable report generated from CI artifacts, so that failures can be reviewed without rerunning locally. -39. As a tech lead, I want Flutter Pilot to build on `mcp_flutter`, so that the project benefits from existing Flutter runtime inspection, screenshots, interactions, logs, and lifecycle tools. +39. As a tech lead, I want Flutter Pilot to build on `pilot_runtime`, so that the project benefits from existing Flutter runtime inspection, screenshots, interactions, logs, and lifecycle tools. 40. As a tech lead, I want Flutter Pilot to avoid reimplementing Flutter driver internals, so that the project stays focused on replay, reporting, and agent-ready diagnostics. 41. As a contributor, I want the scenario parser and runner to be testable without a live Flutter app, so that the core behavior can be developed quickly. -42. As a contributor, I want the `mcp_flutter` integration hidden behind a narrow interface, so that command mapping can evolve without changing the scenario model. +42. As a contributor, I want the `pilot_runtime` integration hidden behind a narrow interface, so that command mapping can evolve without changing the scenario model. 43. As a contributor, I want artifact writing to be isolated in one module, so that report generation and bundle layout remain consistent. ## Implementation Decisions @@ -85,7 +85,7 @@ The result is a reproducible bug report package that can be consumed by humans, - First-slice runtime dependencies are `args`, `yaml`, and `path`. First-slice dev dependencies are `test` and `lints`. - The first implementation slice has been scaffolded as a Dart CLI package with command entrypoint, Scenario model, Scenario parser, validation exception shape, parser tests, and CLI subprocess tests. - Code should follow the local Dart code conventions captured during review: important local variables use explicit types, stateless utility classes expose static methods with a private constructor, parser APIs return typed domain objects on success and throw domain validation exceptions on failure, boolean CLI flags avoid meaningless negative forms, and every code file contains useful doc comments. -- Use `mcp_flutter` as the runtime bridge for Flutter interaction and inspection. Flutter Pilot does not reimplement low-level app driving, widget inspection, screenshot capture, log collection, or lifecycle controls. +- Use `pilot_runtime` as the runtime bridge for Flutter interaction and inspection. Flutter Pilot does not reimplement low-level app driving, widget inspection, screenshot capture, log collection, or lifecycle controls. - The MVP command set includes: - `flutter_pilot validate ` - `flutter_pilot test` @@ -138,12 +138,12 @@ The result is a reproducible bug report package that can be consumed by humans, - Flutter Pilot's Target App Launch Progress renderer owns launch wording, launch choices, heartbeat wording, elapsed-time display, interactive refresh, success summaries, failure summaries, and JSON suppression rules. - Human-readable stderr summary should state `Run passed.`, `Run failed at ...`, or `Stopped after ... due to --until.` as appropriate. Stdout keeps stable report path lines, including `Run report:`, `HTML report:`, and Project Run summaries, for scripts and smoke verification. - CLI subprocess tests may select an in-process fake Runtime Adapter through a test-only environment variable such as `FLUTTER_PILOT_TEST_RUNTIME`. This hook must not appear in `--help` output or become part of the public CLI contract. -- `byKey` is not part of the current Scenario DSL because the calibrated `mcp_flutter` semantic Snapshot path does not expose Flutter key values reliably. Key-based Finders may be added later if the Runtime Adapter can obtain stable key data. -- `byType` accepts the `mcp_flutter` semantic Snapshot node type, such as `textField`, `button`, `text`, `scrollable`, or `header`. It does not accept Dart widget class names such as `TextField`, `FilledButton`, or app-defined wrapper widget classes. +- `byKey` is not part of the current Scenario DSL because the calibrated `pilot_runtime` semantic Snapshot path does not expose Flutter key values reliably. Key-based Finders may be added later if the Runtime Adapter can obtain stable key data. +- `byType` accepts the `pilot_runtime` semantic Snapshot node type, such as `textField`, `button`, `text`, `scrollable`, or `header`. It does not accept Dart widget class names such as `TextField`, `FilledButton`, or app-defined wrapper widget classes. - `byText` matches exact visible text. It does not perform contains, fuzzy, or regular expression matching in the first version. - A Finder must resolve to exactly one widget before an action can execute. Zero matches fail the step as "Finder matched no widgets"; multiple matches fail the step as "Finder matched multiple widgets." Flutter Pilot does not automatically choose the first match. - The initial action set includes `tap`, `type`, `scroll`, `waitFor`, and `capture`. -- The `type` action means replacing text in a widget: clear existing text, then enter the configured text. It is distinct from the `byType` Finder constraint. +- The `type` action means replacing text in a widget: clear existing text directly, then enter the configured text one character at a time. It is distinct from the `byType` Finder constraint. - The `waitFor` action waits for a Finder to produce exactly one match before its timeout. Zero matches keep waiting until timeout, one match succeeds, and multiple matches fail the step. The first version does not support waiting for disappearance, enabled state, or disabled state. - `waitFor.timeoutMs` defaults to `3000` when omitted. The first version supports per-step timeout overrides but no global timeout defaults in the Scenario. - The `scroll` action accepts `deltaX` and `deltaY` as gesture drag deltas in logical pixels. Omitted deltas default to `0`. For example, `deltaY: -500` means dragging upward by 500 logical pixels, which usually reveals lower content. A Finder is optional for `scroll`; when omitted, Flutter Pilot scrolls the primary scrollable. When provided, the Finder must resolve to exactly one scrollable target. At least one of `deltaX` or `deltaY` must be non-zero, so `scroll: {}` and zero-delta scrolls are invalid. @@ -172,9 +172,9 @@ The result is a reproducible bug report package that can be consumed by humans, - Scenario-level device video recording is supported as an optional run-level artifact. When recording is enabled, `test` requires the selected Target Device to also be available as a Recording Device with the same device id. The Device Video Recording is stored under the run directory as `artifacts/device-video-recording.` and recorded in reports with a run-directory-relative path. Richer recording parameters remain out of scope. Step screenshots and timeline reports remain the primary step-level visual artifacts. - The implementation should be organized around deep modules: - Scenario model and parser: validates YAML and produces a typed scenario. - - Finder and action model: represents user intent independently from `mcp_flutter` command details. + - Finder and action model: represents user intent independently from `pilot_runtime` command details. - Runner engine: executes steps, handles `--until`, applies waits, records status, and triggers captures. - - `mcp_flutter` adapter: maps high-level actions and capture requests to `mcp_flutter`. Prefer an available API/SDK call path; use CLI subprocess execution only as a fallback when the API path is unavailable or insufficient. + - `PilotRuntimeAdapter`: maps high-level actions and capture requests to `pilot_runtime`. Prefer an available API/SDK call path; use CLI subprocess execution only as a fallback when the API path is unavailable or insufficient. - Artifact store: owns run directory layout and artifact metadata. - Diagnostic reducer: turns large widget and semantic data into agent-friendly summaries. - Report generator: builds JSON and HTML reports from run results. @@ -188,10 +188,10 @@ The result is a reproducible bug report package that can be consumed by humans, - The first slice included working `validate` behavior and an execution command shell that parsed arguments, validated the Scenario, checked CLI rules, and reported that UI execution was not implemented yet. - The current execution command is `test`; the earlier `run` shell has been removed from the public CLI. - This comes first because every later feature depends on a stable scenario contract and command surface. -2. `mcp_flutter` adapter contract +2. `PilotRuntimeAdapter` contract - Define the narrow interface for tap, type, scroll, wait, screenshot, semantic snapshot, widget data, and logs. - Add a fake adapter for tests before wiring real commands, so runner behavior can be developed without a live Flutter app. - - Manually calibrate `mcp_flutter` before locking the implementation path. Check whether API calls are available and whether they expose richer structured data than CLI output; use API shape as the primary reference while keeping runner-facing types inside Flutter Pilot's own model. The same calibration should verify discovery, screenshot, semantic snapshot, Logs, Finder by text/key/type, multiple Finder Match descriptions, WidgetBounds availability, and scroll without Finder. + - Manually calibrate `pilot_runtime` before locking the implementation path. Check whether API calls are available and whether they expose richer structured data than CLI output; use API shape as the primary reference while keeping runner-facing types inside Flutter Pilot's own model. The same calibration should verify discovery, screenshot, semantic snapshot, Logs, Finder by text/key/type, multiple Finder Match descriptions, WidgetBounds availability, and scroll without Finder. 3. Runner engine with YAML replay - Execute ordered steps through the adapter, record step status, durations, command results, and exit codes. - Support the initial action set: `tap`, `type`, `scroll`, and `waitFor`. @@ -218,7 +218,7 @@ The result is a reproducible bug report package that can be consumed by humans, - Compare two run directories for status changes, visible text changes, semantic/widget summary changes, screenshot differences, resolved runtime failures from logs, and regressions. - This comes after reports and reducers because it depends on stable artifact formats and summary data. 11. Real integration smoke test with a sample Flutter app - - Add a minimal sample app and one end-to-end scenario to validate the real `mcp_flutter` path. + - Add a minimal sample app and one end-to-end scenario to validate the real `pilot_runtime` path. - Keep most coverage in unit and contract tests; use this as a final confidence check for the MVP. ## Testing Decisions @@ -227,12 +227,12 @@ The result is a reproducible bug report package that can be consumed by humans, - First-slice tests already establish the baseline testing style: parser tests call the public parser API and assert on typed Scenario output or structured validation exceptions; CLI tests execute the command through a real Dart subprocess and assert on exit codes and terminal output. - The scenario model and parser should have unit tests for valid YAML, invalid YAML, labels, capture directives, Finders, unsupported actions, combined Finder constraints, rejection of array-valued Finder fields, rejection of unlabeled Step items that only contain a label, and rejection of Steps with multiple action fields. - First-slice tests should be split between parser/model unit tests and a small number of CLI subprocess tests. Parser/model tests cover detailed schema behavior. CLI subprocess tests cover external command behavior such as `validate`, `validate --json`, `test` command-shell behavior, and CLI argument errors. -- The runner engine should have tests using a fake `mcp_flutter` adapter. These tests should verify step ordering, failure handling, automatic capture, `--until`, `--print`, zero Finder matches, one Finder match, multiple Finder matches, `waitFor` success, `waitFor` timeout, `waitFor` multiple-match failure, `scroll` with a Finder, `scroll` without a Finder, and `scroll` delta validation. +- The runner engine should have tests using a fake `PilotRuntimeAdapter`. These tests should verify step ordering, failure handling, automatic capture, `--until`, `--print`, zero Finder matches, one Finder match, multiple Finder matches, `waitFor` success, `waitFor` timeout, `waitFor` multiple-match failure, `scroll` with a Finder, `scroll` without a Finder, and `scroll` delta validation. - The artifact store should have tests verifying stable run directory layout, artifact naming, metadata references, and report paths. - The diagnostic reducer should have tests using representative widget and semantic data fixtures. Tests should verify that visible text, interactive widgets, logs, runtime failures, and route-like context are preserved while noisy details are removed. - The report generator should have tests verifying JSON report shape and HTML timeline content from fixed run fixtures. - The diff engine should have tests comparing fixed before/after fixtures for resolved runtime failures from logs, changed visible text, missing steps, changed screenshots, and unchanged runs. -- The `mcp_flutter` adapter should be covered by contract tests where practical. Most behavior should be tested through mocked command responses to avoid requiring a live Flutter app in unit tests. +- The `PilotRuntimeAdapter` should be covered by contract tests where practical. Most behavior should be tested through mocked command responses to avoid requiring a live Flutter app in unit tests. - End-to-end tests with a sample Flutter app are valuable after the core CLI exists, but they are not required for the first parser and runner slices. - There is no prior test suite in the current repository, so test conventions should be established as part of the initial implementation. @@ -243,13 +243,14 @@ The result is a reproducible bug report package that can be consumed by humans, - Interactive recording of manual app usage into YAML is not part of the MVP. - Automatic source-code patching is not part of this PRD. - Full visual regression testing infrastructure is not part of the MVP beyond before/after run diffing. -- Replacing `mcp_flutter` or building a custom Flutter VM service integration is out of scope. +- Replacing the `pilot_runtime` app-side bridge with a generic Flutter VM + service driver is out of scope. - Supporting every possible Flutter widget Finder in the first release is out of scope. - Cloud artifact hosting and team dashboard features are out of scope. ## Further Notes -- The project now has the first Dart CLI slice in place. The next product risk is no longer YAML parsing; it is defining the narrow `mcp_flutter` adapter contract and keeping runner behavior testable without a live Flutter app. +- The project now has the first Dart CLI slice in place. The next product risk is no longer YAML parsing; it is defining the narrow `PilotRuntimeAdapter` contract and keeping runner behavior testable without a live Flutter app. - The strongest product positioning is not "YAML UI automation"; it is "reproducible Flutter UI debugging artifacts for humans, CI, and AI agents." - Step screenshots are more useful than video for the initial AI-agent use case because they can be directly associated with step metadata, widget summaries, logs, and runtime failures. Scenario-level video recording is complementary run context rather than a replacement for step-owned captures. - The tool should keep raw data available for advanced debugging, but default CLI and agent output should be compact and high signal. diff --git a/docs-internal/pilot-runtime-prd.md b/docs-internal/pilot-runtime-prd.md index 816d826..5eac1a6 100644 --- a/docs-internal/pilot-runtime-prd.md +++ b/docs-internal/pilot-runtime-prd.md @@ -57,7 +57,7 @@ structured UI artifact exposed through `widgetTree`. 20. As a Flutter developer, I want wrapper widgets and child semantics to combine within the same visible target subtree, so that `byWidget` can work with custom button wrappers. 21. As a Flutter developer, I want tap to prefer semantic tap actions when available, so that taps use the most stable Flutter action path. 22. As a Flutter developer, I want tap to fall back to pointer center taps on calibrated platforms, so that targets without semantic tap actions can still be exercised. -23. As a Flutter developer, I want `type` to replace editable text without simulating platform keyboard input, so that text entry is deterministic. +23. As a Flutter developer, I want `type` to clear editable text directly and then enter the configured text character by character without simulating platform keyboard input, so that text entry is deterministic while still exercising per-character text changes. 24. As a Flutter developer, I want scroll deltas to remain Flutter logical pixel drag deltas, so that existing Scenario scroll semantics remain intact. 25. As a Flutter developer, I want untargeted scroll to use the primary scrollable, so that simple scrolling Scenarios remain concise. 26. As a Flutter developer, I want untargeted scroll to fail when the primary scrollable is ambiguous, so that Flutter Pilot does not pick an arbitrary scrollable. @@ -106,7 +106,7 @@ structured UI artifact exposed through `widgetTree`. - Preserve `byType` as Semantic Node Type and do not overload it with Dart widget class matching. - All Finder fields remain single strings and use AND semantics. - Do not add `match`, OR semantics, first-match behavior, priority, or fallback chains. -- `resolveFinder` returns visible Runtime Target matches. Action methods validate whether a match supports tap, text replacement, or scrolling. +- `resolveFinder` returns visible Runtime Target matches. Action methods validate whether a match supports tap, editable text entry, or scrolling. - Finder matching excludes targets known to be offstage, inactive, zero-size, or invisible. Overlay occlusion is not a strong v1 guarantee. - Finder constraints may be satisfied across the same visible target subtree and collapsed into one Runtime Target match. - Finder match diagnostics include separate matched widget type and action widget type when those differ. @@ -140,7 +140,7 @@ Major modules to build or modify: - `pilot_runtime` app-side binding module: registers debug-only service extensions and owns hook lifecycle. - Runtime protocol module: defines request and response envelopes, protocol versioning, capability names, and normalized errors. - Finder resolution module: combines Element, RenderObject, editable text, and Semantics evidence into visible Runtime Target matches. -- Action execution module: performs tap, text replacement, and scroll operations against Runtime Handles. +- Action execution module: performs tap, editable text clear/entry, and scroll operations against Runtime Handles. - Widget Tree normalizer module: converts Inspector summary diagnostics into the normalized Widget Tree artifact model. - Screenshot module: captures Flutter-layer screenshots through a calibrated runtime path. - Hot reload/restart module: wraps VM Service or Flutter runtime reset operations. @@ -161,7 +161,7 @@ Major modules to build or modify: - Runtime client tests should use fake VM Service responses for handshake, protocol mismatch, capability missing, Widget Tree capture, and error mapping. - Runtime protocol tests should cover response decoding, version validation, capability validation, and structured runtime failures. - Finder resolution tests inside `pilot_runtime` should use Flutter widget tests for visible matching, offstage exclusion, `byText`, semantic `byType`, `ValueKey`, `byWidget`, strict AND combinations, and wrapper-child subtree evidence. -- Action execution tests inside `pilot_runtime` should use Flutter widget tests for semantic tap, pointer fallback, editable text replacement, targeted scroll, and primary scrollable resolution. +- Action execution tests inside `pilot_runtime` should use Flutter widget tests for semantic tap, pointer fallback, editable text clear/entry, targeted scroll, and primary scrollable resolution. - Widget Tree normalizer tests should use recorded Inspector summary tree fixtures and verify normalized schema, source, node fields, child structure, missing optional fields, and rejection of invalid required shape. - Screenshot tests should verify returned MIME type and bytes shape where the chosen screenshot path can be faked; real screenshot quality should be covered by calibration smoke tests. - Logs tests should verify the not-implemented payload and that `logs: true` does not fail a capture Step. diff --git a/examples/smoke_app/README.md b/examples/smoke_app/README.md index 230cf31..cb8498e 100644 --- a/examples/smoke_app/README.md +++ b/examples/smoke_app/README.md @@ -1,6 +1,6 @@ # Flutter Pilot Smoke App -This is the minimal Flutter app used by the real `mcp_flutter` smoke path. +This is the minimal Flutter app used by the real `pilot_runtime` smoke path. Run it in debug mode: @@ -9,16 +9,14 @@ cd examples/smoke_app /Users/drown/development/flutter/bin/flutter run -d macos --debug ``` -Copy the VM service websocket URI from Flutter output, then run from the repo -root: +From `examples/smoke_app`, run the smoke Scenario. Flutter Pilot will launch +the app itself: ```bash -dart run tool/run_mcp_flutter_smoke.dart ws://127.0.0.1://ws +dart run ../../bin/flutter_pilot.dart test smoke_scenario.yaml --device macos ``` -The smoke script validates the Runtime Target with `flutter-mcp-toolkit`, then -runs `examples/smoke_scenario.yaml` through Flutter Pilot and prints the -generated `run_report.json` path. +The run prints the generated `run_report.json` and `timeline.html` paths. ## PilotRuntimeAdapter Tap Acceptance @@ -42,7 +40,6 @@ Stop that manual run before executing Flutter Pilot. From `examples/smoke_app`, run the passing Scenario; Flutter Pilot will launch the app itself: ```bash -FLUTTER_PILOT_RUNTIME=pilot_runtime \ dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_tap.yaml \ --target lib/pilot_runtime_tap_demo.dart \ --device macos @@ -58,7 +55,6 @@ Expected result: To verify the failure path, run: ```bash -FLUTTER_PILOT_RUNTIME=pilot_runtime \ dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_non_tappable.yaml \ --target lib/pilot_runtime_tap_demo.dart \ --device macos @@ -70,17 +66,42 @@ Expected result: - the failure reason contains `cannot be tapped` - failure diagnostics are still written under the run directory -## Optional CI +## PilotRuntimeAdapter Scroll Acceptance -The real `mcp_flutter` smoke path is available as an opt-in GitHub Actions -workflow: +The smoke app includes a dedicated target for checking scroll replay through the +real `pilot_runtime` binding. + +To inspect the UI manually, run the scroll demo target on a debug device: + +```bash +/Users/drown/development/flutter/bin/flutter run -d macos --debug -t lib/pilot_runtime_scroll_demo.dart +``` + +Stop that manual run before executing Flutter Pilot. From `examples/smoke_app`, +run the passing Scenario; Flutter Pilot will launch the app itself: + +```bash +dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_scroll.yaml \ + --target lib/pilot_runtime_scroll_demo.dart \ + --device macos +``` -```text -Real mcp_flutter Smoke +Expected result: + +- the Scenario passes +- the list scrolls until `Scroll demo row 18` is visible +- the run writes `run_report.json` and `timeline.html` + +To verify the failure path, run: + +```bash +dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_scroll_non_scrollable.yaml \ + --target lib/pilot_runtime_scroll_demo.dart \ + --device macos ``` -It is triggered manually with `workflow_dispatch`; it is not part of the default -unit-test CI. The workflow starts this smoke app on a macOS runner, extracts the -VM service websocket URI from `flutter run --machine`, runs -`tool/run_mcp_flutter_smoke.dart`, and uploads `.runs` as workflow artifacts for -inspection. +Expected result: + +- the Scenario fails at `scroll_read_only_text` +- the failure reason contains `does not identify a scrollable` +- failure diagnostics are still written under the run directory diff --git a/examples/smoke_app/lib/main.dart b/examples/smoke_app/lib/main.dart index 4ff7898..5fcbba5 100644 --- a/examples/smoke_app/lib/main.dart +++ b/examples/smoke_app/lib/main.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:mcp_toolkit/mcp_toolkit.dart'; +import 'package:pilot_runtime/pilot_runtime.dart'; -/// Start the Flutter Pilot smoke app with MCP Toolkit extensions enabled. -Future main() async { - await MCPToolkitBinding.instance.bootstrapFlutter( - runApp: () => runApp(const SmokeApp()), - ); +/// Start the Flutter Pilot smoke app with pilot_runtime extensions enabled. +void main() { + WidgetsFlutterBinding.ensureInitialized(); + PilotRuntimeBinding.ensureInitialized(); + runApp(const SmokeApp()); } -/// Minimal Flutter app used by the real `mcp_flutter` smoke Scenario. +/// Minimal Flutter app used by the real `pilot_runtime` smoke Scenario. class SmokeApp extends StatelessWidget { const SmokeApp({super.key}); diff --git a/examples/smoke_app/lib/pilot_runtime_probe_app.dart b/examples/smoke_app/lib/pilot_runtime_probe_app.dart index bcecab8..f861b05 100644 --- a/examples/smoke_app/lib/pilot_runtime_probe_app.dart +++ b/examples/smoke_app/lib/pilot_runtime_probe_app.dart @@ -10,7 +10,7 @@ import 'package:flutter/material.dart'; /// /// This target is not the production smoke app. It exists so runtime research /// can verify a `pilot_runtime`-style app hook against a real Flutter debug -/// Runtime Target without depending on `mcp_toolkit`. +/// Runtime Target with an app-side probe binding. void main() { WidgetsFlutterBinding.ensureInitialized(); PilotRuntimeProbeHook.instance.register(); diff --git a/examples/smoke_app/lib/pilot_runtime_scroll_demo.dart b/examples/smoke_app/lib/pilot_runtime_scroll_demo.dart new file mode 100644 index 0000000..9bd1fef --- /dev/null +++ b/examples/smoke_app/lib/pilot_runtime_scroll_demo.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:pilot_runtime/pilot_runtime.dart'; + +/// Start the scroll replay demo with the real `pilot_runtime` binding installed. +/// +/// This target is intended for manual Flutter Pilot acceptance runs. It exposes +/// one targeted scrollable, one read-only non-scrollable target, and enough list +/// content for a Scenario to prove that pointer drag replay moved the list. +void main() { + WidgetsFlutterBinding.ensureInitialized(); + PilotRuntimeBinding.ensureInitialized(); + runApp(const PilotRuntimeScrollDemoApp()); +} + +/// Demo app used to verify scroll replay through `PilotRuntimeAdapter`. +class PilotRuntimeScrollDemoApp extends StatelessWidget { + const PilotRuntimeScrollDemoApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Pilot Runtime Scroll Demo', + theme: ThemeData(colorSchemeSeed: Colors.teal), + home: const PilotRuntimeScrollDemoPage(), + ); + } +} + +/// Single-screen target for targeted scroll and failing scroll checks. +class PilotRuntimeScrollDemoPage extends StatelessWidget { + const PilotRuntimeScrollDemoPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Pilot Runtime Scroll Demo')), + body: Column( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Scroll replay acceptance', + key: ValueKey('scroll_demo_header'), + ), + ), + const Text( + 'Read only scroll target', + key: ValueKey('read_only_scroll_target'), + ), + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + key: const ValueKey('target_scroll_list'), + padding: const EdgeInsets.all(16), + itemCount: 40, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + height: 56, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Scroll demo row $index', + key: ValueKey('scroll_demo_row_$index'), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/examples/smoke_app/pilot/pilot_runtime_scroll.yaml b/examples/smoke_app/pilot/pilot_runtime_scroll.yaml new file mode 100644 index 0000000..942f996 --- /dev/null +++ b/examples/smoke_app/pilot/pilot_runtime_scroll.yaml @@ -0,0 +1,16 @@ +scenario: + name: pilot_runtime_scroll + description: | + Verifies targeted scroll replay through PilotRuntimeAdapter. + +steps: + - label: scroll_target_list + scroll: + byKey: target_scroll_list + byType: scrollable + deltaY: -900 + + - label: wait_for_late_row + waitFor: + byText: Scroll demo row 18 + timeoutMs: 5000 diff --git a/examples/smoke_app/pilot/pilot_runtime_scroll_non_scrollable.yaml b/examples/smoke_app/pilot/pilot_runtime_scroll_non_scrollable.yaml new file mode 100644 index 0000000..021b252 --- /dev/null +++ b/examples/smoke_app/pilot/pilot_runtime_scroll_non_scrollable.yaml @@ -0,0 +1,11 @@ +scenario: + name: pilot_runtime_scroll_non_scrollable + description: | + Verifies that PilotRuntimeAdapter fails clearly for non-scrollable matches. + +steps: + - label: scroll_read_only_text + scroll: + byText: Read only scroll target + byType: text + deltaY: -300 diff --git a/examples/smoke_app/pubspec.lock b/examples/smoke_app/pubspec.lock index 27ab4ae..71c0f48 100644 --- a/examples/smoke_app/pubspec.lock +++ b/examples/smoke_app/pubspec.lock @@ -49,22 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" - dart_mcp: - dependency: transitive - description: - name: dart_mcp - sha256: c133c6f3b9668ed5e674bbf6f29a792f3abed9aeba53ec391c92f65f248acddf - url: "https://pub.dev" - source: hosted - version: "0.5.1" - equatable: - dependency: transitive - description: - name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" - url: "https://pub.dev" - source: hosted - version: "2.0.8" fake_async: dependency: transitive description: @@ -91,30 +75,6 @@ packages: description: flutter source: sdk version: "0.0.0" - from_json_to_json: - dependency: transitive - description: - name: from_json_to_json - sha256: a29219df65cd20b1b17be8141ee51b3103d1cd95b192b7512139f3ae44775f49 - url: "https://pub.dev" - source: hosted - version: "0.5.0" - is_dart_empty_or_not: - dependency: transitive - description: - name: is_dart_empty_or_not - sha256: "1454632c2b961175d4c2807310713cbcbd054a51e63c545dc86c3eb9b2b061b0" - url: "https://pub.dev" - source: hosted - version: "0.4.0" - json_rpc_2: - dependency: transitive - description: - name: json_rpc_2 - sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" - url: "https://pub.dev" - source: hosted - version: "4.1.0" leak_tracker: dependency: transitive description: @@ -163,14 +123,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" - mcp_toolkit: - dependency: "direct main" - description: - name: mcp_toolkit - sha256: "447ed60d357a63484c9a79470c584af48b86af693e0f35b35f6abf9cf2a13770" - url: "https://pub.dev" - source: hosted - version: "3.0.0" meta: dependency: transitive description: @@ -223,14 +175,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" string_scanner: dependency: transitive description: @@ -255,22 +199,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 - url: "https://pub.dev" - source: hosted - version: "2.3.1" vector_math: dependency: transitive description: diff --git a/examples/smoke_app/pubspec.yaml b/examples/smoke_app/pubspec.yaml index a3a0b0f..4b7d8b2 100644 --- a/examples/smoke_app/pubspec.yaml +++ b/examples/smoke_app/pubspec.yaml @@ -34,7 +34,6 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - mcp_toolkit: ^3.0.0 pilot_runtime: path: ../../packages/pilot_runtime diff --git a/examples/smoke_scenario.yaml b/examples/smoke_app/smoke_scenario.yaml similarity index 76% rename from examples/smoke_scenario.yaml rename to examples/smoke_app/smoke_scenario.yaml index 518088f..dd0df04 100644 --- a/examples/smoke_scenario.yaml +++ b/examples/smoke_app/smoke_scenario.yaml @@ -1,9 +1,14 @@ scenario: name: smoke_runtime description: | - Exercises Flutter Pilot against the sample Flutter app through mcp_flutter. + Exercises Flutter Pilot against the sample Flutter app through pilot_runtime. steps: + - label: wait_for_smoke_form + waitFor: + byText: Smoke form + timeoutMs: 5000 + - label: enter_email type: byType: textField @@ -18,10 +23,3 @@ steps: waitFor: byText: Smoke validation failed timeoutMs: 5000 - - - - label: capture_runtime - capture: - screenshot: true - widgetTree: true - logs: true diff --git a/issues/0.1.1-pilot-runtime.md b/issues/0.1.1-pilot-runtime.md index 2c4d8c6..6608339 100644 --- a/issues/0.1.1-pilot-runtime.md +++ b/issues/0.1.1-pilot-runtime.md @@ -248,15 +248,16 @@ https://github.com/drown0315/flutter_pilot/issues/110 ## What to build -Implement text replacement through `pilot_runtime` and `PilotRuntimeAdapter`. A Scenario type action should resolve a unique Runtime Target match, verify that the match is editable text, replace existing text deterministically, and fail clearly for non-editable targets without simulating keyboard or IME input. +Implement text entry through `pilot_runtime` and `PilotRuntimeAdapter`. A Scenario type action should resolve a unique Runtime Target match, verify that the match is editable text, clear existing text deterministically, then enter the Scenario text one character at a time. Non-editable targets should fail clearly without simulating platform keyboard or IME input. ## Acceptance criteria -- [ ] `PilotRuntimeAdapter.replaceText()` calls the `pilot_runtime` text replacement capability with Runtime Handle and text. -- [ ] Editable text targets have existing text replaced by the Scenario text. -- [ ] Non-editable targets fail with a clear message. -- [ ] The implementation does not simulate platform keyboard or IME input. -- [ ] Tests cover adapter mapping, successful replacement, non-editable failure, and Step failure reporting. +- [x] `PilotRuntimeAdapter.clearText()` calls the `pilot_runtime` clear-text capability with Runtime Handle. +- [x] `PilotRuntimeAdapter.enterText()` calls the `pilot_runtime` enter-text capability with Runtime Handle and one text fragment. +- [x] Editable text targets have existing text cleared before Scenario text is entered character by character. +- [x] Non-editable targets fail with a clear message. +- [x] The implementation does not simulate platform keyboard or IME input. +- [x] Tests cover adapter mapping, successful text entry, non-editable failure, and Step failure reporting. ## Blocked by @@ -264,6 +265,10 @@ Implement text replacement through `pilot_runtime` and `PilotRuntimeAdapter`. A ## 9. Implement scroll action with targeted and primary scrollables +Status: completed in `3792e7e feat(runtime): implement pilot runtime scroll replay`. +Follow-up coverage stabilizes keyed `ListView` plus `byType: scrollable` +resolution for the smoke Scenario. + ## Parent https://github.com/drown0315/flutter_pilot/issues/110 @@ -274,12 +279,23 @@ Implement scroll replay through `pilot_runtime` and `PilotRuntimeAdapter`. Targe ## Acceptance criteria -- [ ] Targeted scroll uses a unique Finder match and fails when the match is not scrollable. -- [ ] Untargeted scroll resolves the primary scrollable. -- [ ] Untargeted scroll fails clearly when the primary scrollable is missing or ambiguous. -- [ ] Scroll uses pointer drag gestures with the Scenario `deltaX` and `deltaY` logical pixel semantics. -- [ ] Semantic scroll actions are not used for v1. -- [ ] Tests cover adapter mapping, targeted scroll, non-scrollable failure, primary scrollable success, and ambiguous primary failure. +- [x] Targeted scroll uses a unique Finder match and fails when the match is not scrollable. +- [x] Untargeted scroll resolves the primary scrollable. +- [x] Untargeted scroll fails clearly when the primary scrollable is missing or ambiguous. +- [x] Scroll uses pointer drag gestures with the Scenario `deltaX` and `deltaY` logical pixel semantics. +- [x] Semantic scroll actions are not used for v1. +- [x] Tests cover adapter mapping, targeted scroll, non-scrollable failure, primary scrollable success, and ambiguous primary failure. + +Verification: + +- `dart format .` +- `dart analyze` +- `dart test` +- `flutter analyze` from `packages/pilot_runtime` +- `flutter test` from `packages/pilot_runtime` +- `flutter analyze` from `examples/smoke_app` +- `dart run bin/flutter_pilot.dart validate examples/smoke_app/pilot/pilot_runtime_scroll.yaml` +- `dart run bin/flutter_pilot.dart validate examples/smoke_app/pilot/pilot_runtime_scroll_non_scrollable.yaml` ## Blocked by diff --git a/lib/flutter_pilot.dart b/lib/flutter_pilot.dart index f8df0da..df07aa1 100644 --- a/lib/flutter_pilot.dart +++ b/lib/flutter_pilot.dart @@ -15,7 +15,6 @@ export 'src/recording/recording_contract.dart'; export 'src/recording/screen_recorder_recording_controller.dart'; export 'src/project_scenario_discovery.dart'; export 'src/project_run_report.dart'; -export 'src/runtime/mcp_flutter_runtime_adapter.dart'; export 'src/runtime/pilot_runtime_adapter.dart'; export 'src/runtime/pilot_runtime_vm_service.dart'; export 'src/runtime/runtime_adapter_selector.dart'; diff --git a/lib/src/app_setup.dart b/lib/src/app_setup.dart index 2fe1252..962f50f 100644 --- a/lib/src/app_setup.dart +++ b/lib/src/app_setup.dart @@ -5,30 +5,30 @@ import 'package:yaml/yaml.dart'; /// Setup state for the current Target App Package. /// /// It records whether the current package is a Flutter package, whether -/// `mcp_toolkit` is available as a runtime dependency, and whether the app -/// entrypoint appears to call `bootstrapFlutter`. +/// `pilot_runtime` is available as a runtime dependency, and whether the app +/// entrypoint appears to initialize `PilotRuntimeBinding`. class AppSetupStatus { const AppSetupStatus({ required this.isFlutterPackage, - required this.hasMcpToolkitDependency, - required this.hasBootstrapFlutter, + required this.hasPilotRuntimeDependency, + required this.hasPilotRuntimeBinding, }); /// Whether `pubspec.yaml` declares `dependencies.flutter.sdk: flutter`. final bool isFlutterPackage; - /// Whether `mcp_toolkit` is declared under runtime `dependencies`. - final bool hasMcpToolkitDependency; + /// Whether `pilot_runtime` is declared under runtime `dependencies`. + final bool hasPilotRuntimeDependency; - /// Whether `lib/main.dart` contains the MCP Toolkit bootstrap call. - final bool hasBootstrapFlutter; + /// Whether `lib/main.dart` contains the pilot runtime binding call. + final bool hasPilotRuntimeBinding; /// Whether all required Flutter Pilot app setup was found. bool get isComplete => - isFlutterPackage && hasMcpToolkitDependency && hasBootstrapFlutter; + isFlutterPackage && hasPilotRuntimeDependency && hasPilotRuntimeBinding; } -/// Result of trying to add `mcp_toolkit` to a Target App Package. +/// Result of trying to add `pilot_runtime` to a Target App Package. class AppSetupInstallResult { const AppSetupInstallResult.success() : exitCode = 0, stderr = ''; @@ -51,14 +51,14 @@ class AppSetupInstallResult { class AppSetupInitResult { const AppSetupInitResult({ required this.status, - required this.addedMcpToolkitDependency, + required this.addedPilotRuntimeDependency, }); /// Setup status observed before any dependency installation. final AppSetupStatus status; - /// Whether initialization successfully ran `flutter pub add mcp_toolkit`. - final bool addedMcpToolkitDependency; + /// Whether initialization successfully ran `flutter pub add pilot_runtime`. + final bool addedPilotRuntimeDependency; } /// Adds the safe Flutter Pilot dependency setup when it is missing. @@ -69,38 +69,40 @@ typedef AppSetupDependencyInstaller = class AppSetupInitializer { AppSetupInitializer._(); - /// Add `mcp_toolkit` when the current package lacks the runtime dependency. + /// Add `pilot_runtime` when the current package lacks the runtime dependency. /// /// Args: /// `packageDirectory` is the Target App Package root. - /// `addMcpToolkitDependency` runs the dependency tool. Production code uses - /// `flutter pub add mcp_toolkit`; tests can pass a fake installer. + /// `addPilotRuntimeDependency` runs the dependency tool. Production code uses + /// `flutter pub add pilot_runtime`; tests can pass a fake installer. /// /// Returns: /// Whether the dependency install was run and the setup status observed /// before the install. The Dart entrypoint is never modified. static Future initialize( Directory packageDirectory, { - required AppSetupDependencyInstaller addMcpToolkitDependency, + required AppSetupDependencyInstaller addPilotRuntimeDependency, }) async { final AppSetupStatus status = AppSetupChecker.check(packageDirectory); - if (!status.hasMcpToolkitDependency) { - final AppSetupInstallResult installResult = await addMcpToolkitDependency( - packageDirectory, - ); + if (!status.hasPilotRuntimeDependency) { + final AppSetupInstallResult installResult = + await addPilotRuntimeDependency(packageDirectory); if (!installResult.succeeded) { throw AppSetupInstallException(installResult); } return AppSetupInitResult( status: status, - addedMcpToolkitDependency: true, + addedPilotRuntimeDependency: true, ); } - return AppSetupInitResult(status: status, addedMcpToolkitDependency: false); + return AppSetupInitResult( + status: status, + addedPilotRuntimeDependency: false, + ); } } -/// Failure returned when `flutter pub add mcp_toolkit` does not succeed. +/// Failure returned when `flutter pub add pilot_runtime` does not succeed. class AppSetupInstallException implements Exception { const AppSetupInstallException(this.result); @@ -112,8 +114,7 @@ class AppSetupInstallException implements Exception { class AppSetupChecker { AppSetupChecker._(); - static const String bootstrapCall = - 'MCPToolkitBinding.instance.bootstrapFlutter'; + static const String bindingCall = 'PilotRuntimeBinding.ensureInitialized'; /// Inspect `packageDirectory` without modifying files. /// @@ -129,22 +130,22 @@ class AppSetupChecker { if (!pubspecFile.existsSync()) { return const AppSetupStatus( isFlutterPackage: false, - hasMcpToolkitDependency: false, - hasBootstrapFlutter: false, + hasPilotRuntimeDependency: false, + hasPilotRuntimeBinding: false, ); } final Object? pubspec = loadYaml(pubspecFile.readAsStringSync()); final bool isFlutterPackage = _isDeclaresFlutterSdk(pubspec); - final bool hasMcpToolkitDependency = _hasRuntimeMcpToolkit(pubspec); + final bool hasPilotRuntimeDependency = _hasRuntimePilotRuntime(pubspec); final File mainFile = File('${packageDirectory.path}/lib/main.dart'); - final bool hasBootstrapFlutter = + final bool hasPilotRuntimeBinding = mainFile.existsSync() && - mainFile.readAsStringSync().contains(bootstrapCall); + mainFile.readAsStringSync().contains(bindingCall); return AppSetupStatus( isFlutterPackage: isFlutterPackage, - hasMcpToolkitDependency: hasMcpToolkitDependency, - hasBootstrapFlutter: hasBootstrapFlutter, + hasPilotRuntimeDependency: hasPilotRuntimeDependency, + hasPilotRuntimeBinding: hasPilotRuntimeBinding, ); } @@ -156,9 +157,9 @@ class AppSetupChecker { return false; } - /// Return whether the pubspec declares `mcp_toolkit` as a runtime dependency. - static bool _hasRuntimeMcpToolkit(Object? pubspec) { - if (pubspec case {'dependencies': {'mcp_toolkit': Object()}}) { + /// Return whether the pubspec declares `pilot_runtime` as a runtime dependency. + static bool _hasRuntimePilotRuntime(Object? pubspec) { + if (pubspec case {'dependencies': {'pilot_runtime': Object()}}) { return true; } return false; diff --git a/lib/src/commands/app_setup_commands.dart b/lib/src/commands/app_setup_commands.dart index 46e1e8e..f9e24ff 100644 --- a/lib/src/commands/app_setup_commands.dart +++ b/lib/src/commands/app_setup_commands.dart @@ -33,12 +33,12 @@ class DoctorCommand extends Command { if (status.isComplete) { stdout.writeln('✅ Flutter Pilot app setup is complete.'); } else { - if (!status.hasMcpToolkitDependency) { + if (!status.hasPilotRuntimeDependency) { stdout.writeln( - '❌ MCP Toolkit dependency missing: run `flutter pub add mcp_toolkit`', + '❌ pilot_runtime dependency missing: run `flutter pub add pilot_runtime`', ); } - if (!status.hasBootstrapFlutter) { + if (!status.hasPilotRuntimeBinding) { _writeBootstrapGuidance(); } } @@ -55,8 +55,8 @@ class DoctorCommand extends Command { /// `init` command for adding safe Flutter Pilot setup to a Target App Package. /// -/// It can add the `mcp_toolkit` dependency through Flutter tooling, then -/// reports whether the app entrypoint still needs manual bootstrap code. +/// It can add the `pilot_runtime` dependency through Flutter tooling, then +/// reports whether the app entrypoint still needs manual binding code. class InitCommand extends Command { @override String get description => @@ -78,23 +78,23 @@ class InitCommand extends Command { } final AppSetupInitResult result = await AppSetupInitializer.initialize( Directory.current, - addMcpToolkitDependency: _addMcpToolkitDependency, + addPilotRuntimeDependency: _addPilotRuntimeDependency, ); stdout.writeln('Flutter Pilot init'); stdout.writeln(''); - if (result.addedMcpToolkitDependency) { - stdout.writeln('✅ Added MCP Toolkit dependency.'); + if (result.addedPilotRuntimeDependency) { + stdout.writeln('✅ Added pilot_runtime dependency.'); } else { - stdout.writeln('✅ MCP Toolkit dependency already exists.'); + stdout.writeln('✅ pilot_runtime dependency already exists.'); } - if (result.status.hasBootstrapFlutter) { - stdout.writeln('✅ bootstrapFlutter already exists.'); + if (result.status.hasPilotRuntimeBinding) { + stdout.writeln('✅ PilotRuntimeBinding already exists.'); } else { _writeBootstrapGuidance(); } return 0; } on AppSetupInstallException catch (error) { - stderr.writeln('Failed to add MCP Toolkit dependency.'); + stderr.writeln('Failed to add pilot_runtime dependency.'); if (error.result.stderr.isNotEmpty) { stderr.writeln(''); stderr.writeln('flutter pub add output:'); @@ -102,7 +102,7 @@ class InitCommand extends Command { } stderr.writeln(''); stderr.writeln('Run this command manually from the Flutter package:'); - stderr.writeln('flutter pub add mcp_toolkit'); + stderr.writeln('flutter pub add pilot_runtime'); return 1; } on FileSystemException catch (error) { stderr.writeln(error.message); @@ -115,14 +115,14 @@ class InitCommand extends Command { } /// Run Flutter tooling to add the runtime dependency. -Future _addMcpToolkitDependency( +Future _addPilotRuntimeDependency( Directory packageDirectory, ) async { try { final ProcessResult result = await Process.run('flutter', [ 'pub', 'add', - 'mcp_toolkit', + 'pilot_runtime', ], workingDirectory: packageDirectory.path); if (result.exitCode == 0) { return const AppSetupInstallResult.success(); @@ -136,21 +136,21 @@ Future _addMcpToolkitDependency( } } -/// Print the manual app entrypoint change required for MCP Toolkit. +/// Print the manual app entrypoint change required for pilot_runtime. void _writeBootstrapGuidance() { stdout.writeln( - '❌ bootstrapFlutter missing: add ' - 'MCPToolkitBinding.instance.bootstrapFlutter in lib/main.dart', + '❌ PilotRuntimeBinding missing: add ' + 'PilotRuntimeBinding.ensureInitialized() in lib/main.dart', ); stdout.writeln(''); - stdout.writeln('Add the MCP Toolkit import:'); - stdout.writeln("import 'package:mcp_toolkit/mcp_toolkit.dart';"); + stdout.writeln('Add the pilot_runtime import:'); + stdout.writeln("import 'package:pilot_runtime/pilot_runtime.dart';"); stdout.writeln(''); - stdout.writeln('Wrap runApp with MCPToolkitBinding:'); - stdout.writeln('Future main() async {'); - stdout.writeln(' await MCPToolkitBinding.instance.bootstrapFlutter('); - stdout.writeln(' runApp: () => runApp(const MyApp()),'); - stdout.writeln(' );'); + stdout.writeln('Initialize PilotRuntimeBinding before runApp:'); + stdout.writeln('void main() {'); + stdout.writeln(' WidgetsFlutterBinding.ensureInitialized();'); + stdout.writeln(' PilotRuntimeBinding.ensureInitialized();'); + stdout.writeln(' runApp(const MyApp());'); stdout.writeln('}'); } diff --git a/lib/src/runtime/mcp_flutter_runtime_adapter.dart b/lib/src/runtime/mcp_flutter_runtime_adapter.dart deleted file mode 100644 index 971b244..0000000 --- a/lib/src/runtime/mcp_flutter_runtime_adapter.dart +++ /dev/null @@ -1,486 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import '../scenario.dart'; -import 'runtime_contract.dart'; - -/// Function that executes one `flutter-mcp-toolkit` command. -/// -/// Tests can pass a fake runner. Production code uses -/// `McpFlutterRuntimeAdapter.defaultCommandRunner` to call the installed CLI. -typedef McpFlutterCommandRunner = - Future> Function(McpFlutterCommandCall call); - -/// Command request sent to `flutter-mcp-toolkit`. -/// -/// It contains the command name and JSON-compatible arguments passed to -/// `flutter-mcp-toolkit exec --name --args `. -class McpFlutterCommandCall { - const McpFlutterCommandCall({required this.name, required this.arguments}); - - final String name; - final Map arguments; -} - -/// Runtime Adapter backed by the `flutter-mcp-toolkit` CLI. -/// -/// The adapter keeps `flutter-mcp-toolkit` response shapes behind the -/// Flutter Pilot Runtime Adapter contract. It always passes the configured VM -/// service URI through the command `connection` argument. -class McpFlutterRuntimeAdapter implements RuntimeAdapter { - const McpFlutterRuntimeAdapter({ - required this.target, - this.commandRunner = defaultCommandRunner, - }); - - final RuntimeTarget target; - final McpFlutterCommandRunner commandRunner; - - /// Execute a `flutter-mcp-toolkit` command through the installed CLI. - /// - /// Args: - /// `call` contains the command name and JSON-compatible arguments. - /// - /// Returns: - /// The decoded JSON response envelope. - /// - /// Throws: - /// `RuntimeOperationException` when the process fails or prints invalid JSON. - static Future> defaultCommandRunner( - McpFlutterCommandCall call, - ) async { - final ProcessResult result = await Process.run('flutter-mcp-toolkit', [ - 'exec', - '--name', - call.name, - '--args', - jsonEncode(call.arguments), - ]); - final Object? decoded; - try { - decoded = jsonDecode(result.stdout.toString()); - } on FormatException catch (error) { - if (result.exitCode != 0) { - throw RuntimeOperationException( - operation: _operationForCommand(call.name), - message: 'flutter-mcp-toolkit command failed: ${call.name}.', - cause: error, - rawOutput: { - 'stdout': result.stdout.toString(), - 'stderr': result.stderr.toString(), - 'exitCode': result.exitCode, - }, - ); - } - throw RuntimeOperationException( - operation: _operationForCommand(call.name), - message: 'flutter-mcp-toolkit returned invalid JSON.', - cause: error, - rawOutput: result.stdout.toString(), - ); - } - if (decoded is Map) { - return decoded; - } - throw RuntimeOperationException( - operation: _operationForCommand(call.name), - message: 'flutter-mcp-toolkit returned a non-object JSON response.', - rawOutput: decoded, - ); - } - - @override - Future initialize() async { - await _execute( - RuntimeOperation.initialize, - 'get_extension_rpcs', - const {}, - ); - } - - @override - Future dispose() async {} - - @override - Future> resolveFinder(Finder finder) async { - final Object? data = await _execute( - RuntimeOperation.resolveFinder, - 'semantic_snapshot', - const {}, - ); - final List> nodes = _snapshotNodes(data); - return [ - for (final Map node in nodes) - if (_matchesFinder(node, finder)) _matchFromNode(node), - ]; - } - - @override - Future performTap(FinderMatch match) async { - await _execute(RuntimeOperation.performTap, 'tap_widget', { - 'ref': match.id, - }); - } - - @override - Future replaceText(FinderMatch match, String text) async { - await _execute( - RuntimeOperation.replaceText, - 'enter_text', - {'ref': match.id, 'text': text}, - ); - } - - @override - Future performScroll({ - FinderMatch? match, - required double deltaX, - required double deltaY, - }) async { - await _execute(RuntimeOperation.performScroll, 'scroll', { - 'direction': _scrollDirection(deltaX: deltaX, deltaY: deltaY), - 'distance': _scrollDistance(deltaX: deltaX, deltaY: deltaY), - if (match != null) 'ref': match.id, - }); - } - - @override - Future captureScreenshot() async { - final Object? data = await _executeFirstSuccessful( - RuntimeOperation.captureScreenshot, - [ - const McpFlutterCommandCall( - name: 'get_screenshots', - arguments: { - 'permissionPolicy': 'auto_request_once', - 'mode': 'auto', - }, - ), - const McpFlutterCommandCall( - name: 'get_screenshots', - arguments: { - 'permissionPolicy': 'auto_request_once', - 'mode': 'flutter_layer', - }, - ), - const McpFlutterCommandCall( - name: 'capture_ui_snapshot', - arguments: { - 'screenshotMode': 'flutter_layer', - 'permissionPolicy': 'auto_request_once', - 'includeViewDetails': false, - 'includeErrors': false, - }, - ), - ], - ); - final String? base64Image = _firstScreenshotImage(data); - if (base64Image == null) { - throw RuntimeOperationException( - operation: RuntimeOperation.captureScreenshot, - message: 'flutter-mcp-toolkit returned no screenshot images.', - rawOutput: data, - ); - } - return ScreenshotCapture( - bytes: Uint8List.fromList(base64Decode(base64Image)), - mimeType: 'image/png', - ); - } - - /// Execute commands in order and return the first successful `data`. - /// - /// Args: - /// `operation` is used for the final failure if every command fails. - /// `calls` are tried sequentially. Each call receives the adapter connection - /// argument before it is sent to `flutter-mcp-toolkit`. - /// - /// Returns: - /// The `data` field from the first successful command response. - /// - /// Throws: - /// `RuntimeOperationException` containing all command failure messages when - /// no command succeeds. - Future _executeFirstSuccessful( - RuntimeOperation operation, - List calls, - ) async { - final List failures = []; - for (final McpFlutterCommandCall call in calls) { - try { - return await _execute(operation, call.name, call.arguments); - } on RuntimeOperationException catch (error) { - failures.add('${call.name}: ${error.message}'); - } - } - throw RuntimeOperationException( - operation: operation, - message: failures.join('; '), - ); - } - - @override - Future captureSnapshot() async { - return SnapshotCapture( - data: await _captureSemanticSnapshot(RuntimeOperation.captureSnapshot), - ); - } - - @override - Future captureWidgetTree() async { - return WidgetTreeCapture( - data: await _captureSemanticSnapshot(RuntimeOperation.captureWidgetTree), - ); - } - - /// Capture `semantic_snapshot` data for Snapshot and widget-tree outputs. - /// - /// Flutter Pilot keeps separate public capture types because Scenario output - /// still distinguishes Snapshot from widget-tree diagnostics. The current - /// `mcp_flutter` integration uses the semantic snapshot command for both - /// paths because `get_view_details` is not reliable enough as widget context. - Future _captureSemanticSnapshot(RuntimeOperation operation) async { - final Object? data = await _execute( - operation, - 'semantic_snapshot', - const {}, - ); - return data ?? const {}; - } - - @override - Future collectLogs() async { - final Object? data = await _execute( - RuntimeOperation.collectLogs, - 'get_app_errors', - const {}, - ); - return LogsCapture(data: data ?? const {}); - } - - /// Execute one toolkit command and return its `data` field. - Future _execute( - RuntimeOperation operation, - String name, - Map arguments, - ) async { - final Map envelope; - try { - envelope = await commandRunner( - McpFlutterCommandCall( - name: name, - arguments: { - ...arguments, - 'connection': { - 'mode': 'uri', - 'uri': target.vmServiceUri.toString(), - }, - }, - ), - ); - } on RuntimeOperationException catch (error) { - throw RuntimeOperationException( - operation: operation, - message: error.message, - cause: error.cause, - rawOutput: error.rawOutput, - ); - } - if (envelope['ok'] == true) { - return envelope['data']; - } - throw RuntimeOperationException( - operation: operation, - message: _errorMessage(envelope['error']) ?? '$name failed.', - rawOutput: envelope, - ); - } - - /// Return semantic snapshot nodes from known toolkit response shapes. - List> _snapshotNodes(Object? data) { - if (data is! Map) { - return const >[]; - } - final Object? nodes = data['nodes'] ?? data['widgets'] ?? data['items']; - if (nodes is! List) { - return const >[]; - } - return >[ - for (final Object? node in nodes) - if (node is Map) node, - ]; - } - - /// Return whether one semantic snapshot node satisfies a Finder. - bool _matchesFinder(Map node, Finder finder) { - return _matchesOptional(finder.byText, _nodeText(node)) && - _matchesOptional(finder.byType, _nodeType(node)) && - _matchesOptional(finder.byKey, _nodeKey(node)) && - _matchesOptional(finder.byWidget, _nodeWidgetType(node)); - } - - /// Convert one semantic snapshot node to a Flutter Pilot Finder Match. - FinderMatch _matchFromNode(Map node) { - return FinderMatch( - id: _stringField(node, const ['ref', 'id']) ?? '', - debugLabel: _nodeText(node) ?? _nodeType(node), - text: _nodeText(node), - key: _nodeKey(node), - type: _nodeType(node), - bounds: _boundsFromNode(node), - ); - } - - /// Return optional bounds from a semantic snapshot node. - WidgetBounds? _boundsFromNode(Map node) { - final Object? rect = node['rect'] ?? node['bounds']; - if (rect is! Map) { - return null; - } - final num? left = _numberField(rect, 'left'); - final num? top = _numberField(rect, 'top'); - final num? width = _numberField(rect, 'width'); - final num? height = _numberField(rect, 'height'); - if (left == null || top == null || width == null || height == null) { - return null; - } - return WidgetBounds( - left: left.toDouble(), - top: top.toDouble(), - width: width.toDouble(), - height: height.toDouble(), - ); - } - - String? _nodeText(Map node) { - return _stringField(node, const ['label', 'text', 'name']); - } - - String? _nodeKey(Map node) { - return _stringField(node, const ['key', 'valueKey']); - } - - /// Return the Dart widget runtime type display name when exposed by snapshot. - String? _nodeWidgetType(Map node) { - return _stringField(node, const [ - 'widgetType', - 'runtimeType', - 'widget', - ]); - } - - /// Return the `mcp_flutter` semantic node type used by Finder `byType`. - /// - /// Real `semantic_snapshot` responses expose semantic types such as - /// `textField`, `button`, `text`, and `scrollable`, not Dart widget class - /// names such as `TextField` or `FilledButton`. - String? _nodeType(Map node) { - return _stringField(node, const ['type', 'widgetType']); - } - - bool _matchesOptional(String? expected, String? actual) { - return expected == null || actual == expected; - } - - String? _stringField(Map map, List keys) { - for (final String key in keys) { - final Object? value = map[key]; - if (value is String) { - return value; - } - } - return null; - } - - num? _numberField(Map map, String key) { - final Object? value = map[key]; - return value is num ? value : null; - } - - String? _errorMessage(Object? error) { - if (error is Map) { - final Object? message = error['message']; - if (message is String) { - return message; - } - } - return null; - } - - /// Return the first screenshot image from toolkit screenshot output. - String? _firstScreenshotImage(Object? data) { - if (data is! Map) { - return null; - } - final String? directImage = _stringField(data, const [ - 'image', - 'base64', - 'png', - ]); - if (directImage != null) { - return directImage; - } - final Object? screenshot = data['screenshot']; - if (screenshot is Map) { - final String? screenshotImage = _firstScreenshotImage(screenshot); - if (screenshotImage != null) { - return screenshotImage; - } - } - final Object? images = data['images']; - final String? imageFromImages = _firstImageFromList(images); - if (imageFromImages != null) { - return imageFromImages; - } - return _firstImageFromList(data['screenshots']); - } - - /// Return the first base64 image string from a toolkit image list. - String? _firstImageFromList(Object? images) { - if (images is! List || images.isEmpty) { - return null; - } - for (final Object? image in images) { - if (image is String) { - return image; - } - if (image is Map) { - final String? nestedImage = _firstScreenshotImage(image); - if (nestedImage != null) { - return nestedImage; - } - } - } - return null; - } - - String _scrollDirection({required double deltaX, required double deltaY}) { - if (deltaY.abs() >= deltaX.abs()) { - return deltaY < 0 ? 'down' : 'up'; - } - return deltaX < 0 ? 'right' : 'left'; - } - - int _scrollDistance({required double deltaX, required double deltaY}) { - final double distance = deltaY.abs() >= deltaX.abs() - ? deltaY.abs() - : deltaX.abs(); - return distance.round(); - } - - /// Return the Runtime Operation that best describes a toolkit command. - static RuntimeOperation _operationForCommand(String name) { - return switch (name) { - 'tap_widget' => RuntimeOperation.performTap, - 'enter_text' => RuntimeOperation.replaceText, - 'scroll' => RuntimeOperation.performScroll, - 'get_screenshots' => RuntimeOperation.captureScreenshot, - 'capture_ui_snapshot' => RuntimeOperation.captureScreenshot, - 'semantic_snapshot' => RuntimeOperation.captureSnapshot, - 'get_app_errors' => RuntimeOperation.collectLogs, - 'get_extension_rpcs' => RuntimeOperation.initialize, - _ => RuntimeOperation.initialize, - }; - } -} diff --git a/lib/src/runtime/pilot_runtime_adapter.dart b/lib/src/runtime/pilot_runtime_adapter.dart index 1a6029c..1c07ea1 100644 --- a/lib/src/runtime/pilot_runtime_adapter.dart +++ b/lib/src/runtime/pilot_runtime_adapter.dart @@ -7,7 +7,7 @@ import 'runtime_contract.dart'; /// /// This first adapter slice verifies the app-side runtime handshake and exposes /// Widget Tree capture through Flutter Pilot's existing Runtime Adapter -/// contract. User actions are implemented in later runtime slices. +/// contract. class PilotRuntimeAdapter implements RuntimeAdapter { /// Create an adapter backed by a checked `pilot_runtime` client. const PilotRuntimeAdapter({ @@ -88,8 +88,41 @@ class PilotRuntimeAdapter implements RuntimeAdapter { } @override - Future replaceText(FinderMatch match, String text) { - throw _notImplemented(RuntimeOperation.replaceText); + Future clearText(FinderMatch match) async { + try { + await _client.clearText(handle: match.id); + } on PilotRuntimeActionException catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.clearText, + message: error.message, + cause: error, + ); + } catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.clearText, + message: error.toString(), + cause: error, + ); + } + } + + @override + Future enterText(FinderMatch match, String text) async { + try { + await _client.enterText(handle: match.id, text: text); + } on PilotRuntimeActionException catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.enterText, + message: error.message, + cause: error, + ); + } catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.enterText, + message: error.toString(), + cause: error, + ); + } } @override @@ -97,8 +130,26 @@ class PilotRuntimeAdapter implements RuntimeAdapter { FinderMatch? match, required double deltaX, required double deltaY, - }) { - throw _notImplemented(RuntimeOperation.performScroll); + }) async { + try { + await _client.performScroll( + handle: match?.id, + deltaX: deltaX, + deltaY: deltaY, + ); + } on PilotRuntimeActionException catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.performScroll, + message: error.message, + cause: error, + ); + } catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.performScroll, + message: error.toString(), + cause: error, + ); + } } @override diff --git a/lib/src/runtime/runtime_adapter_selector.dart b/lib/src/runtime/runtime_adapter_selector.dart index 4eaf481..89d7ace 100644 --- a/lib/src/runtime/runtime_adapter_selector.dart +++ b/lib/src/runtime/runtime_adapter_selector.dart @@ -2,15 +2,14 @@ import 'dart:io'; import 'package:pilot_runtime/pilot_runtime.dart'; -import 'mcp_flutter_runtime_adapter.dart'; import 'pilot_runtime_adapter.dart'; import 'pilot_runtime_vm_service.dart'; import 'runtime_contract.dart'; /// Selects the Runtime Adapter for a launched Runtime Target. /// -/// The default path stays on `mcp_flutter`. A hidden environment switch is -/// reserved for experimental runtime adapters without adding public CLI flags. +/// The default path uses `pilot_runtime`. A hidden environment switch remains +/// available for explicit runtime selection without adding public CLI flags. class RuntimeAdapterSelector { RuntimeAdapterSelector._(); @@ -30,11 +29,8 @@ class RuntimeAdapterSelector { }) { final Map effectiveEnvironment = environment ?? Platform.environment; - final String? runtime = effectiveEnvironment[environmentKey]; - if (runtime == null || runtime.isEmpty || runtime == 'mcp_flutter') { - return McpFlutterRuntimeAdapter(target: target); - } - if (runtime == 'pilot_runtime') { + final String runtime = effectiveEnvironment[environmentKey] ?? ''; + if (runtime.isEmpty || runtime == 'pilot_runtime') { final PilotRuntimeVmServiceConnection vmService = PilotRuntimeVmServiceConnection(vmServiceUri: target.vmServiceUri); return PilotRuntimeAdapter( @@ -45,7 +41,7 @@ class RuntimeAdapterSelector { } throw RuntimeAdapterSelectionException( 'Invalid $environmentKey value "$runtime". ' - 'Expected "mcp_flutter" or "pilot_runtime".', + 'Expected "pilot_runtime".', ); } } diff --git a/lib/src/runtime/runtime_contract.dart b/lib/src/runtime/runtime_contract.dart index eec926b..116a1e3 100644 --- a/lib/src/runtime/runtime_contract.dart +++ b/lib/src/runtime/runtime_contract.dart @@ -16,7 +16,7 @@ class RuntimeTarget { /// Boundary used by the runner to operate on a Flutter Runtime Target. /// /// Implementations map Flutter Pilot actions and capture requests to a concrete -/// runtime bridge such as `mcp_flutter`. The runner owns Scenario control flow; +/// runtime bridge such as `pilot_runtime`. The runner owns Scenario control flow; /// the adapter owns runtime communication and returns Flutter Pilot model /// objects. abstract interface class RuntimeAdapter { @@ -48,18 +48,27 @@ abstract interface class RuntimeAdapter { /// Tap the widget represented by a Finder Match from the current Step. Future performTap(FinderMatch match); - /// Replace the text in the widget represented by a Finder Match. + /// Clear text in the widget represented by a Finder Match. /// /// Args: /// `match` is the target produced by the Step's Finder resolution. - /// `text` is the replacement text from the Scenario `type` action. - Future replaceText(FinderMatch match, String text); + /// + /// The runner calls this once before entering the Scenario `type` text. + Future clearText(FinderMatch match); + + /// Enter text in the widget represented by a Finder Match. + /// + /// Args: + /// `match` is the target produced by the Step's Finder resolution. + /// `text` is the text fragment to enter. The Scenario runner sends one + /// character at a time for `type` actions. + Future enterText(FinderMatch match, String text); /// Perform a drag gesture using Flutter Pilot scroll delta semantics. /// /// Args: /// `match` is an optional scroll target. When omitted, implementation support - /// depends on the real `mcp_flutter` calibration result. + /// depends on the active Runtime Adapter implementation. /// `deltaX` and `deltaY` are logical-pixel drag deltas. Future performScroll({ FinderMatch? match, @@ -105,8 +114,8 @@ class FinderMatch { /// Logical-pixel rectangle for a matched widget in the Flutter view. /// -/// Bounds are optional because the first `mcp_flutter` calibration must confirm -/// whether this data is available and which coordinate space it uses. +/// Bounds are optional because each Runtime Adapter must confirm whether this +/// data is available and which coordinate space it uses. class WidgetBounds { const WidgetBounds({ required this.left, @@ -164,7 +173,8 @@ class LogsCapture { enum RuntimeOperation { resolveFinder, performTap, - replaceText, + clearText, + enterText, performScroll, captureScreenshot, captureSnapshot, diff --git a/lib/src/scenario_runner.dart b/lib/src/scenario_runner.dart index 923c2dd..59a6dd4 100644 --- a/lib/src/scenario_runner.dart +++ b/lib/src/scenario_runner.dart @@ -525,7 +525,7 @@ class ScenarioRunner { ), TypeAction(:final Finder finder, :final String text) => _withUniqueMatch( finder, - (FinderMatch match) => adapter.replaceText(match, text), + (FinderMatch match) => _executeType(match, text), actionName: 'type', ), ScrollAction( @@ -556,6 +556,18 @@ class ScenarioRunner { }; } + /// Execute a Type Action as direct clear followed by character entry. + /// + /// The Runtime Adapter owns target-specific text mutation. The runner keeps + /// the Scenario semantics stable by clearing once and then sending one + /// character at a time instead of pasting the full value. + Future _executeType(FinderMatch match, String text) async { + await adapter.clearText(match); + for (final int rune in text.runes) { + await adapter.enterText(match, String.fromCharCode(rune)); + } + } + /// Resolve `finder`, execute `operation`, and return no Step artifacts. Future<_ActionExecutionResult> _withUniqueMatch( Finder finder, diff --git a/lib/src/smoke_verifier.dart b/lib/src/smoke_verifier.dart index a992f7e..c736097 100644 --- a/lib/src/smoke_verifier.dart +++ b/lib/src/smoke_verifier.dart @@ -1,9 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:path/path.dart' as p; - -/// Exit codes used by the real `mcp_flutter` smoke verifier. +/// Exit codes used by the real Runtime Adapter smoke verifier. /// /// These codes keep CI failures distinguishable: /// - `usage`: invalid verifier invocation @@ -29,7 +27,7 @@ class SmokeVerifierExitCodes { /// - `errors`: every contract failure found in the report or artifacts /// /// Example: -/// A passed report with screenshot, Widget Tree, and Logs artifacts has an empty +/// A passed report where every required smoke Step passed has an empty /// `errors` list. class SmokeVerificationResult { const SmokeVerificationResult({ @@ -49,23 +47,16 @@ class SmokeVerificationResult { /// current real Finder integration contract: /// - the run status is `passed` /// - the Finder Steps in `examples/smoke_scenario.yaml` passed -/// - the capture Step lists screenshot, Widget Tree, and Logs artifacts -/// - each required artifact path exists under the run directory class SmokeRunVerifier { SmokeRunVerifier._(); static const String runReportPrefix = 'Run report:'; static const Set _finderStepLabels = { + 'wait_for_smoke_form', 'enter_email', 'submit_form', 'wait_for_error', }; - static const String _captureStepLabel = 'capture_runtime'; - static const Set _requiredCaptureArtifactTypes = { - 'screenshot', - 'widgetTree', - 'logs', - }; /// Return the report path printed by `flutter_pilot test`. /// @@ -87,7 +78,7 @@ class SmokeRunVerifier { return null; } - /// Check one `run_report.json` file and its referenced capture artifacts. + /// Check one `run_report.json` file and its required smoke Steps. /// /// Args: /// `reportFile` points to the report produced by the smoke Scenario run. @@ -129,11 +120,6 @@ class SmokeRunVerifier { _verifyRunStatus(decoded, errors); final List> steps = _reportSteps(decoded, errors); _verifyFinderSteps(steps, errors); - _verifyCaptureArtifacts( - steps: steps, - runDirectory: reportFile.parent, - errors: errors, - ); return SmokeVerificationResult(reportPath: reportFile.path, errors: errors); } @@ -183,61 +169,6 @@ class SmokeRunVerifier { } } - /// Verify capture Step artifacts are listed and present on disk. - static void _verifyCaptureArtifacts({ - required List> steps, - required Directory runDirectory, - required List errors, - }) { - final Map? captureStep = _stepByLabel( - steps, - _captureStepLabel, - ); - if (captureStep == null) { - errors.add('Missing capture Step: $_captureStepLabel.'); - return; - } - if (captureStep['status'] != 'passed') { - errors.add( - 'Expected capture Step $_captureStepLabel to pass, ' - 'got ${captureStep['status']}.', - ); - } - - final Object? rawArtifacts = captureStep['artifacts']; - if (rawArtifacts is! List) { - errors.add('Expected capture Step artifacts to be a list.'); - return; - } - final List> artifacts = >[ - for (final Object? artifact in rawArtifacts) - if (artifact is Map) artifact, - ]; - - for (final String requiredType in _requiredCaptureArtifactTypes) { - final Map? artifact = _artifactByType( - artifacts, - requiredType, - ); - if (artifact == null) { - errors.add('Missing capture artifact type: $requiredType.'); - continue; - } - final Object? relativePath = artifact['path']; - if (relativePath is! String || relativePath.isEmpty) { - errors.add('Capture artifact $requiredType has no path.'); - continue; - } - final File artifactFile = File(p.join(runDirectory.path, relativePath)); - if (!artifactFile.existsSync()) { - errors.add( - 'Missing capture artifact file for $requiredType: ' - '${artifactFile.path}', - ); - } - } - } - /// Return the Step with the requested label. static Map? _stepByLabel( List> steps, @@ -250,17 +181,4 @@ class SmokeRunVerifier { } return null; } - - /// Return the artifact with the requested type. - static Map? _artifactByType( - List> artifacts, - String type, - ) { - for (final Map artifact in artifacts) { - if (artifact['type'] == type) { - return artifact; - } - } - return null; - } } diff --git a/lib/src/test_scenario_runner_factory.dart b/lib/src/test_scenario_runner_factory.dart index 0dbd6d1..035ec96 100644 --- a/lib/src/test_scenario_runner_factory.dart +++ b/lib/src/test_scenario_runner_factory.dart @@ -30,7 +30,7 @@ abstract interface class TestScenarioRunner { }); } -/// Default Scenario runner factory backed by `McpFlutterRuntimeAdapter`. +/// Default Scenario runner factory backed by `PilotRuntimeAdapter`. class DefaultTestScenarioRunnerFactory implements TestScenarioRunnerFactory { /// Creates the default runner factory. const DefaultTestScenarioRunnerFactory(); diff --git a/packages/pilot_runtime/lib/src/finder_resolver.dart b/packages/pilot_runtime/lib/src/finder_resolver.dart index dfef718..3cb9b2f 100644 --- a/packages/pilot_runtime/lib/src/finder_resolver.dart +++ b/packages/pilot_runtime/lib/src/finder_resolver.dart @@ -48,7 +48,7 @@ class PilotRuntimeFinderResolver { PilotRuntimeFinderMatch( handle: handleForElement(element), text: evidence.textForDiagnostics, - semanticType: evidence.semanticType, + semanticType: evidence.semanticTypeForDiagnostics, key: evidence.valueKey, matchedWidgetType: evidence.widgetType, actionWidgetType: element.widget.runtimeType.toString(), @@ -89,6 +89,19 @@ class PilotRuntimeFinderResolver { return match; } + /// Visit every currently visible Element in the app tree. + /// + /// Runtime action performers use this to preserve the same visible-target + /// rules as Finder resolution when they need to locate related widgets, such + /// as the primary scrollable for an untargeted Scroll Action. + static void visitVisibleElements(void Function(Element element) visitor) { + final Element? rootElement = WidgetsBinding.instance.rootElement; + if (rootElement == null) { + return; + } + _visitVisibleElements(rootElement, visitor); + } + static void _visitVisibleElements( Element element, void Function(Element element) visitor, @@ -112,13 +125,17 @@ class PilotRuntimeFinderResolver { }) { final bool hasStructuralConstraint = byType != null || byKey != null || byWidget != null; + final bool hasWrapperConstraint = byKey != null || byWidget != null; final String? comparableText = hasStructuralConstraint ? evidence.textForDiagnostics : evidence.ownText; + final String? comparableSemanticType = hasWrapperConstraint + ? evidence.semanticType ?? evidence.descendantSemanticType + : evidence.semanticType; if (byText != null && comparableText != byText) { return false; } - if (byType != null && evidence.semanticType != byType) { + if (byType != null && comparableSemanticType != byType) { return false; } if (byKey != null && evidence.valueKey != byKey) { @@ -141,6 +158,7 @@ class PilotRuntimeFinderResolver { ownText: ownText, descendantText: descendantText, semanticType: semanticType, + descendantSemanticType: _descendantSemanticTypeFor(element), valueKey: _valueKeyFor(element), widgetType: element.widget.runtimeType.toString(), ); @@ -180,9 +198,24 @@ class PilotRuntimeFinderResolver { return matchedText; } + static String? _descendantSemanticTypeFor(Element element) { + String? matchedType; + element.visitChildren((Element child) { + matchedType ??= _semanticTypeFor(child); + matchedType ??= _descendantSemanticTypeFor(child); + }); + return matchedType; + } + static String? _semanticTypeFor(Element element) { final Widget widget = element.widget; - if (widget is EditableText || widget is TextField) { + if (widget is TextField) { + return 'textField'; + } + if (widget is EditableText) { + if (_hasAncestorWidget(element)) { + return null; + } return 'textField'; } if (widget is ButtonStyleButton || @@ -282,6 +315,7 @@ class _FinderEvidence { this.ownText, this.descendantText, this.semanticType, + this.descendantSemanticType, this.valueKey, this.widgetType, }); @@ -289,8 +323,12 @@ class _FinderEvidence { final String? ownText; final String? descendantText; final String? semanticType; + final String? descendantSemanticType; final String? valueKey; final String? widgetType; String? get textForDiagnostics => ownText ?? descendantText; + + String? get semanticTypeForDiagnostics => + semanticType ?? descendantSemanticType; } diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart index 109037d..153f152 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart @@ -6,7 +6,9 @@ import 'package:flutter/foundation.dart'; import 'finder_resolver.dart'; import 'pilot_runtime_protocol.dart'; +import 'scroll_performer.dart'; import 'tap_performer.dart'; +import 'text_performer.dart'; /// Handles one app-side Flutter Pilot service extension request. /// @@ -63,6 +65,9 @@ class PilotRuntimeBinding { _handleResolveFinder, ); registrar(PilotRuntimeProtocol.tapExtension, _handleTap); + registrar(PilotRuntimeProtocol.clearTextExtension, _handleClearText); + registrar(PilotRuntimeProtocol.enterTextExtension, _handleEnterText); + registrar(PilotRuntimeProtocol.scrollExtension, _handleScroll); _initialized = true; } @@ -100,6 +105,33 @@ class PilotRuntimeBinding { ); } + static Future> _handleClearText( + Map parameters, + ) async { + return PilotRuntimeTextPerformer.clearText( + handle: _requiredString(parameters, 'handle', 'clearText'), + ); + } + + static Future> _handleEnterText( + Map parameters, + ) async { + return PilotRuntimeTextPerformer.enterText( + handle: _requiredString(parameters, 'handle', 'enterText'), + text: _requiredString(parameters, 'text', 'enterText'), + ); + } + + static Future> _handleScroll( + Map parameters, + ) async { + return PilotRuntimeScrollPerformer.scroll( + handle: _optionalString(parameters, 'handle'), + deltaX: _requiredDouble(parameters, 'deltaX', 'scroll'), + deltaY: _requiredDouble(parameters, 'deltaY', 'scroll'), + ); + } + static String? _optionalString( Map parameters, String field, @@ -126,6 +158,27 @@ class PilotRuntimeBinding { throw FormatException('$operation parameter $field must be a string.'); } + static double _requiredDouble( + Map parameters, + String field, + String operation, + ) { + final Object? value = parameters[field]; + if (value is int) { + return value.toDouble(); + } + if (value is double) { + return value; + } + if (value is String) { + final double? parsed = double.tryParse(value); + if (parsed != null) { + return parsed; + } + } + throw FormatException('$operation parameter $field must be a number.'); + } + static void _registerVmServiceExtension( String extensionName, PilotRuntimeExtensionHandler handler, diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart index 9f31ec3..e187189 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart @@ -140,6 +140,15 @@ enum PilotRuntimeActionFailure { /// A resolved Runtime Handle cannot receive the requested tap action. notTappable, + /// A resolved Runtime Handle cannot receive editable text actions. + notEditableText, + + /// A resolved Runtime Handle cannot receive scroll drag gestures. + notScrollable, + + /// The primary scrollable is missing or ambiguous for untargeted scroll. + primaryScrollableUnavailable, + /// The app-side runtime returned an action failure code this client does not /// understand yet. unknown, @@ -517,6 +526,61 @@ class PilotRuntimeClient { _checkActionResponse(response, actionName: 'tap'); } + /// Clear editable text for one Runtime Handle returned by Finder resolution. + /// + /// Args: + /// - `handle`: Opaque Runtime Handle from a `PilotRuntimeFinderMatch`. + /// + /// Returns when the app-side runtime directly cleared the editable target. + /// Non-editable targets throw `PilotRuntimeActionException`. + Future clearText({required String handle}) async { + final Map response = await _vmService.callServiceExtension( + PilotRuntimeProtocol.clearTextExtension, + parameters: {'handle': handle}, + ); + _checkActionResponse(response, actionName: 'clearText'); + } + + /// Enter text for one Runtime Handle returned by Finder resolution. + /// + /// Args: + /// - `handle`: Opaque Runtime Handle from a `PilotRuntimeFinderMatch`. + /// - `text`: Text fragment to append. Flutter Pilot sends one character at a + /// time for Scenario `type` actions. + Future enterText({required String handle, required String text}) async { + final Map response = await _vmService.callServiceExtension( + PilotRuntimeProtocol.enterTextExtension, + parameters: {'handle': handle, 'text': text}, + ); + _checkActionResponse(response, actionName: 'enterText'); + } + + /// Scroll a Runtime Handle or the primary scrollable by drag deltas. + /// + /// Args: + /// - `handle`: Optional opaque Runtime Handle from a Finder Match. When + /// omitted, the app-side runtime resolves the primary scrollable. + /// - `deltaX`: Horizontal drag distance in logical pixels. + /// - `deltaY`: Vertical drag distance in logical pixels. + Future performScroll({ + String? handle, + required double deltaX, + required double deltaY, + }) async { + final Map parameters = { + 'deltaX': deltaX, + 'deltaY': deltaY, + }; + if (handle != null) { + parameters['handle'] = handle; + } + final Map response = await _vmService.callServiceExtension( + PilotRuntimeProtocol.scrollExtension, + parameters: parameters, + ); + _checkActionResponse(response, actionName: 'scroll'); + } + void _checkActionResponse( Map response, { required String actionName, @@ -539,6 +603,10 @@ class PilotRuntimeClient { final Object? codeValue = response['code']; final PilotRuntimeActionFailure failure = switch (codeValue) { 'notTappable' => PilotRuntimeActionFailure.notTappable, + 'notEditableText' => PilotRuntimeActionFailure.notEditableText, + 'notScrollable' => PilotRuntimeActionFailure.notScrollable, + 'primaryScrollableUnavailable' => + PilotRuntimeActionFailure.primaryScrollableUnavailable, _ => PilotRuntimeActionFailure.unknown, }; throw PilotRuntimeActionException( diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_protocol.dart b/packages/pilot_runtime/lib/src/pilot_runtime_protocol.dart index 8d8e77f..d496d29 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_protocol.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_protocol.dart @@ -1,6 +1,7 @@ /// Protocol constants shared by the app-side hook and VM Service client. /// -/// Version 1 exposes runtime handshake, visible Finder resolution, and tap. +/// Version 1 exposes runtime handshake, visible Finder resolution, tap, +/// editable text entry, and scroll replay. class PilotRuntimeProtocol { PilotRuntimeProtocol._(); @@ -18,6 +19,17 @@ class PilotRuntimeProtocol { /// VM Service extension used to tap one resolved Runtime Handle. static const String tapExtension = 'ext.flutter_pilot.runtime.tap'; + /// VM Service extension used to clear one editable text Runtime Handle. + static const String clearTextExtension = + 'ext.flutter_pilot.runtime.clearText'; + + /// VM Service extension used to append text to one editable text handle. + static const String enterTextExtension = + 'ext.flutter_pilot.runtime.enterText'; + + /// VM Service extension used to drag one scrollable Runtime Handle. + static const String scrollExtension = 'ext.flutter_pilot.runtime.scroll'; + /// Capability name reported when the handshake extension is available. static const String handshakeCapability = 'runtime.handshake'; @@ -27,11 +39,23 @@ class PilotRuntimeProtocol { /// Capability name reported when tap replay is available. static const String tapCapability = 'runtime.action.tap'; + /// Capability name reported when editable text can be cleared directly. + static const String clearTextCapability = 'runtime.action.clearText'; + + /// Capability name reported when editable text can receive entered text. + static const String enterTextCapability = 'runtime.action.enterText'; + + /// Capability name reported when scroll replay is available. + static const String scrollCapability = 'runtime.action.scroll'; + /// Capabilities that this client requires before Scenario execution. static const Set requiredCapabilities = { handshakeCapability, resolveFinderCapability, tapCapability, + clearTextCapability, + enterTextCapability, + scrollCapability, }; } diff --git a/packages/pilot_runtime/lib/src/scroll_performer.dart b/packages/pilot_runtime/lib/src/scroll_performer.dart new file mode 100644 index 0000000..e11dd3e --- /dev/null +++ b/packages/pilot_runtime/lib/src/scroll_performer.dart @@ -0,0 +1,165 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'finder_resolver.dart'; + +/// Performs scroll actions for targeted and primary scrollables. +/// +/// The performer uses pointer drag gestures with Flutter logical-pixel deltas. +/// It does not use semantic scroll actions, so Scenario `deltaX` and `deltaY` +/// map directly to the drag movement sent to Flutter's gesture binding. +class PilotRuntimeScrollPerformer { + PilotRuntimeScrollPerformer._(); + + static int _nextPointer = 1; + + /// Drag a targeted scrollable or the primary visible scrollable. + /// + /// Args: + /// - `handle`: Optional Runtime Handle. When present, the handle must identify + /// a visible scrollable or a widget subtree that contains one scrollable. + /// - `deltaX`: Horizontal drag distance in logical pixels. + /// - `deltaY`: Vertical drag distance in logical pixels. + /// + /// Returns a structured VM Service response. Missing, ambiguous, or + /// non-scrollable targets return `ok: false` instead of throwing. + static Future> scroll({ + String? handle, + required double deltaX, + required double deltaY, + }) async { + final Element? scrollableElement = handle == null + ? _primaryScrollable() + : _scrollableForHandle(handle); + if (scrollableElement == null) { + return handle == null + ? _failure( + code: 'primaryScrollableUnavailable', + message: + 'Primary scrollable is missing or ambiguous for ' + 'untargeted scroll.', + ) + : _failure( + code: 'notScrollable', + message: 'Runtime Handle $handle does not identify a scrollable.', + ); + } + + final Offset? center = _centerFor(scrollableElement); + if (center == null) { + return _failure( + code: handle == null ? 'primaryScrollableUnavailable' : 'notScrollable', + message: handle == null + ? 'Primary scrollable does not have usable bounds.' + : 'Runtime Handle $handle does not have usable scroll bounds.', + ); + } + + final int pointer = _nextPointer++; + GestureBinding.instance.handlePointerEvent( + PointerAddedEvent(pointer: pointer, position: center), + ); + GestureBinding.instance.handlePointerEvent( + PointerDownEvent( + pointer: pointer, + position: center, + buttons: kPrimaryButton, + ), + ); + final Offset firstDelta = Offset(deltaX, deltaY) / 2.0; + final Offset secondDelta = Offset(deltaX, deltaY) - firstDelta; + _movePointer(pointer: pointer, from: center, delta: firstDelta); + _movePointer( + pointer: pointer, + from: center + firstDelta, + delta: secondDelta, + ); + GestureBinding.instance.handlePointerEvent( + PointerUpEvent( + pointer: pointer, + position: center + Offset(deltaX, deltaY), + ), + ); + GestureBinding.instance.handlePointerEvent( + PointerRemovedEvent( + pointer: pointer, + position: center + Offset(deltaX, deltaY), + ), + ); + return {'ok': true, 'method': 'pointer'}; + } + + static void _movePointer({ + required int pointer, + required Offset from, + required Offset delta, + }) { + GestureBinding.instance.handlePointerEvent( + PointerMoveEvent( + pointer: pointer, + position: from + delta, + delta: delta, + buttons: kPrimaryButton, + ), + ); + } + + static Element? _scrollableForHandle(String handle) { + final Element? element = PilotRuntimeFinderResolver.elementForHandle( + handle, + ); + if (element == null) { + return null; + } + if (element.widget is Scrollable) { + return element; + } + Element? scrollable; + element.visitChildren((Element child) { + scrollable ??= _firstScrollableInSubtree(child); + }); + return scrollable; + } + + static Element? _firstScrollableInSubtree(Element element) { + if (element.widget is Scrollable) { + return element; + } + Element? scrollable; + element.visitChildren((Element child) { + scrollable ??= _firstScrollableInSubtree(child); + }); + return scrollable; + } + + static Element? _primaryScrollable() { + final List scrollables = []; + PilotRuntimeFinderResolver.visitVisibleElements((Element element) { + if (element.widget is Scrollable) { + scrollables.add(element); + } + }); + if (scrollables.length != 1) { + return null; + } + return scrollables.single; + } + + static Offset? _centerFor(Element element) { + final RenderObject? renderObject = element.renderObject; + if (renderObject is! RenderBox || !renderObject.hasSize) { + return null; + } + if (renderObject.size.isEmpty) { + return null; + } + return renderObject.localToGlobal(renderObject.size.center(Offset.zero)); + } + + static Map _failure({ + required String code, + required String message, + }) { + return {'ok': false, 'code': code, 'message': message}; + } +} diff --git a/packages/pilot_runtime/lib/src/text_performer.dart b/packages/pilot_runtime/lib/src/text_performer.dart new file mode 100644 index 0000000..276e3f6 --- /dev/null +++ b/packages/pilot_runtime/lib/src/text_performer.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import 'finder_resolver.dart'; + +/// Performs editable text actions for Runtime Handles returned by Finder resolution. +/// +/// The performer edits Flutter text controllers directly for deterministic +/// Scenario replay. It does not simulate platform keyboards or IME behavior. +class PilotRuntimeTextPerformer { + PilotRuntimeTextPerformer._(); + + /// Clear the current text for one editable Runtime Handle. + /// + /// Returns a structured VM Service response. Non-editable, stale, or + /// malformed handles return `ok: false` instead of throwing. + static Future> clearText({ + required String handle, + }) async { + final EditableTextState? state = _editableStateFor(handle); + if (state == null) { + return _failure(handle); + } + state.updateEditingValue(TextEditingValue.empty); + return {'ok': true}; + } + + /// Append text to one editable Runtime Handle. + /// + /// `text` is the exact fragment received from Flutter Pilot. The Scenario + /// runner sends one character at a time for `type` actions. + static Future> enterText({ + required String handle, + required String text, + }) async { + final EditableTextState? state = _editableStateFor(handle); + if (state == null) { + return _failure(handle); + } + final TextEditingValue currentValue = state.textEditingValue; + final String nextText = currentValue.text + text; + state.updateEditingValue( + TextEditingValue( + text: nextText, + selection: TextSelection.collapsed(offset: nextText.length), + ), + ); + return {'ok': true}; + } + + static EditableTextState? _editableStateFor(String handle) { + final Element? element = PilotRuntimeFinderResolver.elementForHandle( + handle, + ); + if (element == null) { + return null; + } + EditableTextState? state; + if (element is StatefulElement && element.state is EditableTextState) { + state = element.state as EditableTextState; + } + element.visitChildren((Element child) { + state ??= _editableStateFromElement(child); + }); + return state; + } + + static EditableTextState? _editableStateFromElement(Element element) { + if (element is StatefulElement && element.state is EditableTextState) { + return element.state as EditableTextState; + } + EditableTextState? state; + element.visitChildren((Element child) { + state ??= _editableStateFromElement(child); + }); + return state; + } + + static Map _failure(String handle) { + return { + 'ok': false, + 'code': 'notEditableText', + 'message': 'Runtime Handle $handle does not identify editable text.', + }; + } +} diff --git a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart index 389eb80..f3abb56 100644 --- a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pilot_runtime/pilot_runtime.dart'; @@ -24,6 +25,9 @@ void main() { PilotRuntimeProtocol.handshakeExtension, PilotRuntimeProtocol.resolveFinderExtension, PilotRuntimeProtocol.tapExtension, + PilotRuntimeProtocol.clearTextExtension, + PilotRuntimeProtocol.enterTextExtension, + PilotRuntimeProtocol.scrollExtension, ]); expect( await registeredHandlers[PilotRuntimeProtocol.handshakeExtension]!( @@ -32,6 +36,9 @@ void main() { { 'protocolVersion': 1, 'capabilities': [ + 'runtime.action.clearText', + 'runtime.action.enterText', + 'runtime.action.scroll', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -53,5 +60,33 @@ void main() { expect(registeredExtensions, isEmpty); }); + + testWidgets('parses numeric service extension parameters', ( + WidgetTester tester, + ) async { + final Map registeredHandlers = + {}; + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: SingleChildScrollView(child: SizedBox(height: 1000)), + ), + ); + PilotRuntimeBinding.ensureInitialized( + debugMode: true, + registerExtension: + (String extensionName, PilotRuntimeExtensionHandler handler) { + registeredHandlers[extensionName] = handler; + }, + ); + + final Map response = + await registeredHandlers[PilotRuntimeProtocol.scrollExtension]!( + {'deltaX': '0.0', 'deltaY': '-120.5'}, + ); + + expect(response['ok'], true); + }); }); } diff --git a/packages/pilot_runtime/test/pilot_runtime_client_test.dart b/packages/pilot_runtime/test/pilot_runtime_client_test.dart index ab05829..8f7f735 100644 --- a/packages/pilot_runtime/test/pilot_runtime_client_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_client_test.dart @@ -9,6 +9,9 @@ void main() { handshakeResponse: { 'protocolVersion': 1, 'capabilities': [ + 'runtime.action.clearText', + 'runtime.action.enterText', + 'runtime.action.scroll', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -23,6 +26,9 @@ void main() { expect(session.capabilities, contains('runtime.handshake')); expect(session.capabilities, contains('runtime.finder.resolve')); expect(session.capabilities, contains('runtime.action.tap')); + expect(session.capabilities, contains('runtime.action.clearText')); + expect(session.capabilities, contains('runtime.action.enterText')); + expect(session.capabilities, contains('runtime.action.scroll')); expect(vmService.calledExtensions, [ PilotRuntimeProtocol.handshakeExtension, ]); @@ -84,6 +90,8 @@ void main() { handshakeResponse: { 'protocolVersion': 2, 'capabilities': [ + 'runtime.action.clearText', + 'runtime.action.enterText', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -219,6 +227,98 @@ void main() { ); }); }); + + group('PilotRuntimeClient text entry', () { + test('passes opaque Runtime Handle to text extensions', () async { + final FakePilotRuntimeVmService vmService = FakePilotRuntimeVmService( + extensionResponses: >{ + PilotRuntimeProtocol.clearTextExtension: { + 'ok': true, + }, + PilotRuntimeProtocol.enterTextExtension: { + 'ok': true, + }, + }, + ); + final PilotRuntimeClient client = PilotRuntimeClient(vmService); + + await client.clearText(handle: 'runtime-match-1'); + await client.enterText(handle: 'runtime-match-1', text: 'a'); + + expect(vmService.calledExtensions, [ + PilotRuntimeProtocol.clearTextExtension, + PilotRuntimeProtocol.enterTextExtension, + ]); + expect(vmService.calledParameters, >[ + {'handle': 'runtime-match-1'}, + {'handle': 'runtime-match-1', 'text': 'a'}, + ]); + }); + + test('throws typed action failure from structured text response', () async { + final FakePilotRuntimeVmService vmService = FakePilotRuntimeVmService( + extensionResponses: >{ + PilotRuntimeProtocol.clearTextExtension: { + 'ok': false, + 'code': 'notEditableText', + 'message': 'Runtime Handle element-1 is not editable text.', + }, + }, + ); + final PilotRuntimeClient client = PilotRuntimeClient(vmService); + + await expectLater( + client.clearText(handle: 'element-1'), + throwsA( + isA() + .having( + (PilotRuntimeActionException error) => error.failure, + 'failure', + PilotRuntimeActionFailure.notEditableText, + ) + .having( + (PilotRuntimeActionException error) => error.message, + 'message', + 'Runtime Handle element-1 is not editable text.', + ), + ), + ); + }); + }); + + group('PilotRuntimeClient scroll', () { + test( + 'passes optional handle and logical-pixel deltas to scroll extension', + () async { + final FakePilotRuntimeVmService vmService = FakePilotRuntimeVmService( + extensionResponses: >{ + PilotRuntimeProtocol.scrollExtension: {'ok': true}, + }, + ); + final PilotRuntimeClient client = PilotRuntimeClient(vmService); + + await client.performScroll( + handle: 'runtime-match-1', + deltaX: 12.5, + deltaY: -500, + ); + await client.performScroll(deltaX: 0, deltaY: 300); + + expect(vmService.calledExtensions, [ + PilotRuntimeProtocol.scrollExtension, + PilotRuntimeProtocol.scrollExtension, + ]); + expect(vmService.calledParameters, >[ + { + 'deltaX': 12.5, + 'deltaY': -500.0, + 'handle': 'runtime-match-1', + }, + {'deltaX': 0.0, 'deltaY': 300.0}, + ]); + }, + ); + }); } class FakePilotRuntimeVmService implements PilotRuntimeVmService { diff --git a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index 3af137c..1a664c0 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -94,6 +94,40 @@ void main() { expect(match['text'], 'Submit'); }); + testWidgets('resolves one textField match for a Material TextField', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final TextEditingController controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Email'), + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byType: 'textField', + ); + + expect(response['matches'], hasLength(1)); + final Map match = _singleMatch(response); + expect(match['semanticType'], 'textField'); + + final Map clearResponse = await _clearText( + extensions, + handle: match['handle']! as String, + ); + expect(clearResponse['ok'], isTrue); + }); + testWidgets('excludes offstage hidden zero-size and transparent targets', ( WidgetTester tester, ) async { @@ -185,6 +219,37 @@ void main() { expect(intKeyResponse['matches'], isEmpty); }); + testWidgets('combines a keyed ListView with its internal scrollable role', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView.builder( + key: const ValueKey('target_scroll_list'), + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 48, child: Text('Item $index')); + }, + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byKey: 'target_scroll_list', + byType: 'scrollable', + ); + + expect(response['matches'], hasLength(1)); + final Map match = _singleMatch(response); + expect(match['key'], 'target_scroll_list'); + expect(match['semanticType'], 'scrollable'); + }); + testWidgets('resolves exact byWidget runtime type display names', ( WidgetTester tester, ) async { @@ -415,6 +480,222 @@ void main() { expect(tapResponse['code'], 'notTappable'); expect(tapResponse['message'], contains('cannot be tapped')); }); + + testWidgets('clears and appends editable text through a resolved handle', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final TextEditingController controller = TextEditingController( + text: 'old', + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + key: const ValueKey('email_input'), + controller: controller, + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byKey: 'email_input', + byType: 'textField', + ); + final Map match = _singleMatch(response); + + final Map clearResponse = await _clearText( + extensions, + handle: match['handle']! as String, + ); + final Map firstEntryResponse = await _enterText( + extensions, + handle: match['handle']! as String, + text: 'a', + ); + final Map secondEntryResponse = await _enterText( + extensions, + handle: match['handle']! as String, + text: 'b', + ); + await tester.pump(); + + expect(clearResponse['ok'], true); + expect(firstEntryResponse['ok'], true); + expect(secondEntryResponse['ok'], true); + expect(controller.text, 'ab'); + }); + + testWidgets('type fails clearly for a non-editable resolved handle', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: Text('Read only'))), + ); + + final Map response = await _resolveFinder( + extensions, + byText: 'Read only', + ); + final Map match = _singleMatch(response); + + final Map typeResponse = await _clearText( + extensions, + handle: match['handle']! as String, + ); + + expect(typeResponse['ok'], false); + expect(typeResponse['code'], 'notEditableText'); + expect(typeResponse['message'], contains('editable text')); + }); + + testWidgets('scroll drags a targeted scrollable by logical-pixel deltas', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 200, + child: ListView.builder( + key: const ValueKey('target_list'), + controller: controller, + itemCount: 30, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 48, child: Text('Item $index')); + }, + ), + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byKey: 'target_list', + ); + final Map match = _singleMatch(response); + + final Map scrollResponse = await _scroll( + extensions, + handle: match['handle']! as String, + deltaX: 0, + deltaY: -120, + ); + await tester.pumpAndSettle(); + + expect(scrollResponse['ok'], true); + expect(controller.offset, greaterThan(0)); + }); + + testWidgets('scroll fails clearly for a non-scrollable resolved handle', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: Text('Read only'))), + ); + + final Map response = await _resolveFinder( + extensions, + byText: 'Read only', + ); + final Map match = _singleMatch(response); + + final Map scrollResponse = await _scroll( + extensions, + handle: match['handle']! as String, + deltaX: 0, + deltaY: -120, + ); + + expect(scrollResponse['ok'], false); + expect(scrollResponse['code'], 'notScrollable'); + expect(scrollResponse['message'], contains('scrollable')); + }); + + testWidgets('scroll drags the primary scrollable when no handle is given', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 200, + child: ListView.builder( + controller: controller, + itemCount: 30, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 48, child: Text('Item $index')); + }, + ), + ), + ), + ), + ); + + final Map scrollResponse = await _scroll( + extensions, + deltaX: 0, + deltaY: -120, + ); + await tester.pumpAndSettle(); + + expect(scrollResponse['ok'], true); + expect(controller.offset, greaterThan(0)); + }); + + testWidgets('scroll fails when primary scrollable is ambiguous', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + Expanded( + child: ListView( + children: const [Text('Left'), Text('Left 2')], + ), + ), + Expanded( + child: ListView( + children: const [Text('Right'), Text('Right 2')], + ), + ), + ], + ), + ), + ), + ); + + final Map scrollResponse = await _scroll( + extensions, + deltaX: 0, + deltaY: -120, + ); + + expect(scrollResponse['ok'], false); + expect(scrollResponse['code'], 'primaryScrollableUnavailable'); + expect(scrollResponse['message'], contains('ambiguous')); + }); }); } @@ -463,6 +744,42 @@ Future> _tap( }); } +Future> _clearText( + Map extensions, { + required String handle, +}) { + return extensions[PilotRuntimeProtocol.clearTextExtension]!({ + 'handle': handle, + }); +} + +Future> _enterText( + Map extensions, { + required String handle, + required String text, +}) { + return extensions[PilotRuntimeProtocol.enterTextExtension]!({ + 'handle': handle, + 'text': text, + }); +} + +Future> _scroll( + Map extensions, { + String? handle, + required double deltaX, + required double deltaY, +}) { + final Map parameters = { + 'deltaX': deltaX, + 'deltaY': deltaY, + }; + if (handle != null) { + parameters['handle'] = handle; + } + return extensions[PilotRuntimeProtocol.scrollExtension]!(parameters); +} + Map _singleMatch(Map response) { final Object? matchValue = (response['matches']! as List).single; return matchValue! as Map; diff --git a/test/app_setup_test.dart b/test/app_setup_test.dart index ddecf04..b5346b0 100644 --- a/test/app_setup_test.dart +++ b/test/app_setup_test.dart @@ -7,7 +7,7 @@ import 'package:test/test.dart'; /// Exercises Target App Package setup checks through the public app setup API. void main() { test( - 'reports complete setup for a Flutter package with MCP Toolkit', + 'reports complete setup for a Flutter package with pilot_runtime', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') @@ -17,30 +17,31 @@ name: target_app dependencies: flutter: sdk: flutter - mcp_toolkit: ^3.0.0 + pilot_runtime: + path: packages/pilot_runtime '''); _writeMain(packageDirectory, ''' import 'package:flutter/material.dart'; -import 'package:mcp_toolkit/mcp_toolkit.dart'; +import 'package:pilot_runtime/pilot_runtime.dart'; -Future main() async { - await MCPToolkitBinding.instance.bootstrapFlutter( - runApp: () => runApp(const Placeholder()), - ); +void main() { + WidgetsFlutterBinding.ensureInitialized(); + PilotRuntimeBinding.ensureInitialized(); + runApp(const Placeholder()); } '''); final AppSetupStatus status = AppSetupChecker.check(packageDirectory); expect(status.isFlutterPackage, isTrue); - expect(status.hasMcpToolkitDependency, isTrue); - expect(status.hasBootstrapFlutter, isTrue); + expect(status.hasPilotRuntimeDependency, isTrue); + expect(status.hasPilotRuntimeBinding, isTrue); expect(status.isComplete, isTrue); }); }, ); - test('does not accept MCP Toolkit from dev dependencies', () async { + test('does not accept pilot_runtime from dev dependencies', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') ..createSync(recursive: true); @@ -50,27 +51,26 @@ dependencies: flutter: sdk: flutter dev_dependencies: - mcp_toolkit: ^3.0.0 + pilot_runtime: + path: packages/pilot_runtime '''); _writeMain(packageDirectory, ''' -Future main() async { - await MCPToolkitBinding.instance.bootstrapFlutter( - runApp: () {}, - ); +void main() { + PilotRuntimeBinding.ensureInitialized(); } '''); final AppSetupStatus status = AppSetupChecker.check(packageDirectory); expect(status.isFlutterPackage, isTrue); - expect(status.hasMcpToolkitDependency, isFalse); - expect(status.hasBootstrapFlutter, isTrue); + expect(status.hasPilotRuntimeDependency, isFalse); + expect(status.hasPilotRuntimeBinding, isTrue); expect(status.isComplete, isFalse); }); }); test( - 'init adds MCP Toolkit dependency when runtime dependency is missing', + 'init adds pilot_runtime dependency when runtime dependency is missing', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') @@ -85,20 +85,20 @@ dependencies: final AppSetupInitResult result = await AppSetupInitializer.initialize( packageDirectory, - addMcpToolkitDependency: (Directory directory) async { + addPilotRuntimeDependency: (Directory directory) async { installDirectories.add(directory); return const AppSetupInstallResult.success(); }, ); - expect(result.addedMcpToolkitDependency, isTrue); - expect(result.status.hasMcpToolkitDependency, isFalse); + expect(result.addedPilotRuntimeDependency, isTrue); + expect(result.status.hasPilotRuntimeDependency, isFalse); expect(installDirectories, [packageDirectory]); }); }, ); - test('init stops when MCP Toolkit dependency install fails', () async { + test('init stops when pilot_runtime dependency install fails', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') ..createSync(recursive: true); @@ -112,7 +112,7 @@ dependencies: expect( () => AppSetupInitializer.initialize( packageDirectory, - addMcpToolkitDependency: (Directory directory) async { + addPilotRuntimeDependency: (Directory directory) async { return const AppSetupInstallResult.failure( exitCode: 69, stderr: 'pub failed', diff --git a/test/cli_test.dart b/test/cli_test.dart index 7c10ec1..f33d939 100644 --- a/test/cli_test.dart +++ b/test/cli_test.dart @@ -345,18 +345,19 @@ environment: dependencies: flutter: sdk: flutter - mcp_toolkit: ^3.0.0 + pilot_runtime: + path: packages/pilot_runtime '''); final Directory libDirectory = Directory('${tempDirectory.path}/lib') ..createSync(recursive: true); File('${libDirectory.path}/main.dart').writeAsStringSync(''' import 'package:flutter/material.dart'; -import 'package:mcp_toolkit/mcp_toolkit.dart'; +import 'package:pilot_runtime/pilot_runtime.dart'; -Future main() async { - await MCPToolkitBinding.instance.bootstrapFlutter( - runApp: () => runApp(const Placeholder()), - ); +void main() { + WidgetsFlutterBinding.ensureInitialized(); + PilotRuntimeBinding.ensureInitialized(); + runApp(const Placeholder()); } '''); @@ -410,13 +411,13 @@ void main() { expect( result.stdout, contains( - '❌ MCP Toolkit dependency missing: run `flutter pub add mcp_toolkit`', + '❌ pilot_runtime dependency missing: run `flutter pub add pilot_runtime`', ), ); expect( result.stdout, contains( - '❌ bootstrapFlutter missing: add MCPToolkitBinding.instance.bootstrapFlutter in lib/main.dart', + '❌ PilotRuntimeBinding missing: add PilotRuntimeBinding.ensureInitialized() in lib/main.dart', ), ); expect(result.stderr, isEmpty); @@ -499,15 +500,14 @@ environment: dependencies: flutter: sdk: flutter - mcp_toolkit: ^3.0.0 + pilot_runtime: + path: packages/pilot_runtime '''); final Directory libDirectory = Directory('${tempDirectory.path}/lib') ..createSync(recursive: true); File('${libDirectory.path}/main.dart').writeAsStringSync(''' -Future main() async { - await MCPToolkitBinding.instance.bootstrapFlutter( - runApp: () {}, - ); +void main() { + PilotRuntimeBinding.ensureInitialized(); } '''); @@ -521,9 +521,9 @@ Future main() async { expect(result.stdout, contains('Flutter Pilot init')); expect( result.stdout, - contains('✅ MCP Toolkit dependency already exists.'), + contains('✅ pilot_runtime dependency already exists.'), ); - expect(result.stdout, contains('✅ bootstrapFlutter already exists.')); + expect(result.stdout, contains('✅ PilotRuntimeBinding already exists.')); expect(result.stderr, isEmpty); } finally { tempDirectory.deleteSync(recursive: true); @@ -545,7 +545,8 @@ environment: dependencies: flutter: sdk: flutter - mcp_toolkit: ^3.0.0 + pilot_runtime: + path: packages/pilot_runtime '''); final Directory libDirectory = Directory('${tempDirectory.path}/lib') ..createSync(recursive: true); @@ -565,16 +566,16 @@ void main() { expect( result.stdout, contains( - '❌ bootstrapFlutter missing: add MCPToolkitBinding.instance.bootstrapFlutter in lib/main.dart', + '❌ PilotRuntimeBinding missing: add PilotRuntimeBinding.ensureInitialized() in lib/main.dart', ), ); expect( result.stdout, - contains("import 'package:mcp_toolkit/mcp_toolkit.dart';"), + contains("import 'package:pilot_runtime/pilot_runtime.dart';"), ); expect( result.stdout, - contains('await MCPToolkitBinding.instance.bootstrapFlutter('), + contains('PilotRuntimeBinding.ensureInitialized();'), ); } finally { tempDirectory.deleteSync(recursive: true); diff --git a/test/mcp_flutter_runtime_adapter_test.dart b/test/mcp_flutter_runtime_adapter_test.dart deleted file mode 100644 index 5633856..0000000 --- a/test/mcp_flutter_runtime_adapter_test.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'package:flutter_pilot/flutter_pilot.dart'; -import 'package:test/test.dart'; - -/// Exercises the `flutter-mcp-toolkit` Runtime Adapter through a fake command -/// runner. -/// -/// The tests verify Flutter Pilot's public Runtime Adapter contract without -/// requiring a live Flutter app or a real `flutter-mcp-toolkit` process. -void main() { - test('resolves Finder Matches from semantic snapshot refs', () async { - final List calls = []; - final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( - target: RuntimeTarget( - vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), - ), - commandRunner: (McpFlutterCommandCall call) async { - calls.add(call); - return { - 'ok': true, - 'data': { - 'snapshotId': 7, - 'nodes': [ - { - 'ref': 's_0', - 'label': 'Log in', - 'key': 'login_button', - 'type': 'button', - 'widgetType': 'ElevatedButton', - 'rect': { - 'left': 10, - 'top': 20, - 'width': 100, - 'height': 48, - }, - }, - { - 'ref': 's_1', - 'label': 'Cancel', - 'key': 'cancel_button', - 'type': 'button', - }, - ], - }, - 'error': null, - }; - }, - ); - - final List matches = await adapter.resolveFinder( - const Finder( - byText: 'Log in', - byType: 'button', - byKey: 'login_button', - byWidget: 'ElevatedButton', - ), - ); - final List wrongWidgetMatches = await adapter.resolveFinder( - const Finder( - byText: 'Log in', - byType: 'button', - byKey: 'login_button', - byWidget: 'TextButton', - ), - ); - - expect(matches, hasLength(1)); - expect(wrongWidgetMatches, isEmpty); - expect(matches.single.id, 's_0'); - expect(matches.single.debugLabel, 'Log in'); - expect(matches.single.text, 'Log in'); - expect(matches.single.key, 'login_button'); - expect(matches.single.type, 'button'); - expect(matches.single.bounds!.width, 100); - expect(calls.map((McpFlutterCommandCall call) => call.name), [ - 'semantic_snapshot', - 'semantic_snapshot', - ]); - expect(calls.first.arguments, { - 'connection': { - 'mode': 'uri', - 'uri': 'ws://127.0.0.1:1234/example=/ws', - }, - }); - }); - - test('executes actions with Finder Match refs', () async { - final List calls = []; - final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( - target: RuntimeTarget( - vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), - ), - commandRunner: (McpFlutterCommandCall call) async { - calls.add(call); - return {'ok': true, 'data': {}}; - }, - ); - const FinderMatch match = FinderMatch(id: 's_0'); - - await adapter.performTap(match); - await adapter.replaceText(match, 'bad@example.com'); - await adapter.performScroll(match: match, deltaX: 0, deltaY: -500); - - expect(calls.map((McpFlutterCommandCall call) => call.name), [ - 'tap_widget', - 'enter_text', - 'scroll', - ]); - expect(calls[0].arguments['ref'], 's_0'); - expect(calls[1].arguments['ref'], 's_0'); - expect(calls[1].arguments['text'], 'bad@example.com'); - expect(calls[2].arguments['ref'], 's_0'); - expect(calls[2].arguments['direction'], 'down'); - expect(calls[2].arguments['distance'], 500); - }); - - test( - 'captures screenshots, Snapshots, and Logs from toolkit responses', - () async { - final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( - target: RuntimeTarget( - vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), - ), - commandRunner: (McpFlutterCommandCall call) async { - return switch (call.name) { - 'get_screenshots' => { - 'ok': true, - 'data': { - 'images': ['iVBORw=='], - }, - }, - 'semantic_snapshot' => { - 'ok': true, - 'data': { - 'nodes': [ - {'label': 'Log in'}, - ], - }, - }, - 'get_app_errors' => { - 'ok': true, - 'data': { - 'errors': [ - {'message': 'No errors'}, - ], - }, - }, - _ => {'ok': true, 'data': {}}, - }; - }, - ); - - final ScreenshotCapture screenshot = await adapter.captureScreenshot(); - final SnapshotCapture snapshot = await adapter.captureSnapshot(); - final LogsCapture logs = await adapter.collectLogs(); - - expect(screenshot.mimeType, 'image/png'); - expect(screenshot.bytes, [137, 80, 78, 71]); - expect(snapshot.data, isA>()); - expect(logs.data, isA>()); - }, - ); - - test( - 'falls back to flutter layer screenshots when auto capture fails', - () async { - final List calls = []; - final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( - target: RuntimeTarget( - vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), - ), - commandRunner: (McpFlutterCommandCall call) async { - calls.add(call); - if (call.name == 'get_screenshots' && - call.arguments['mode'] == 'auto') { - return { - 'ok': false, - 'data': null, - 'error': {'message': 'desktop capture failed'}, - }; - } - if (call.name == 'get_screenshots' && - call.arguments['mode'] == 'flutter_layer') { - return { - 'ok': true, - 'data': { - 'images': ['iVBORw=='], - }, - }; - } - return {'ok': true, 'data': {}}; - }, - ); - - final ScreenshotCapture screenshot = await adapter.captureScreenshot(); - - expect(screenshot.bytes, [137, 80, 78, 71]); - expect(calls.map((McpFlutterCommandCall call) => call.name), [ - 'get_screenshots', - 'get_screenshots', - ]); - expect(calls[1].arguments['mode'], 'flutter_layer'); - }, - ); - - test('captures Widget Tree from semantic snapshot data', () async { - final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( - target: RuntimeTarget( - vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), - ), - commandRunner: (McpFlutterCommandCall call) async { - expect(call.name, 'semantic_snapshot'); - return { - 'ok': true, - 'data': { - 'nodes': [ - {'type': 'text', 'label': 'Smoke App'}, - ], - }, - }; - }, - ); - - final WidgetTreeCapture widgetTree = await adapter.captureWidgetTree(); - - expect(widgetTree.data, { - 'nodes': [ - {'type': 'text', 'label': 'Smoke App'}, - ], - }); - }); - - test( - 'reports semantic snapshot widget tree failures as widget tree failures', - () async { - final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( - target: RuntimeTarget( - vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), - ), - commandRunner: (McpFlutterCommandCall call) async { - expect(call.name, 'semantic_snapshot'); - throw const RuntimeOperationException( - operation: RuntimeOperation.captureSnapshot, - message: 'semantic snapshot failed', - ); - }, - ); - - await expectLater( - adapter.captureWidgetTree(), - throwsA( - isA().having( - (RuntimeOperationException error) => error.operation, - 'operation', - RuntimeOperation.captureWidgetTree, - ), - ), - ); - }, - ); -} diff --git a/test/pilot_runtime_adapter_test.dart b/test/pilot_runtime_adapter_test.dart index 100dd1d..e379d9c 100644 --- a/test/pilot_runtime_adapter_test.dart +++ b/test/pilot_runtime_adapter_test.dart @@ -191,12 +191,116 @@ void main() { ), ); }); + + test( + 'passes opaque Runtime Handle to pilot_runtime text capabilities', + () async { + final _FakePilotRuntimeClient client = _FakePilotRuntimeClient(); + final PilotRuntimeAdapter adapter = PilotRuntimeAdapter( + client: client, + projectRoot: '/target/app', + ); + + await adapter.clearText(const FinderMatch(id: 'runtime-match-1')); + await adapter.enterText(const FinderMatch(id: 'runtime-match-1'), 'a'); + + expect(client.clearTextHandles, ['runtime-match-1']); + expect(client.enterTextRequests, <({String handle, String text})>[ + (handle: 'runtime-match-1', text: 'a'), + ]); + }, + ); + + test('maps pilot_runtime text failures to action failures', () async { + final PilotRuntimeAdapter adapter = PilotRuntimeAdapter( + client: _FakePilotRuntimeClient( + clearTextFailure: const PilotRuntimeActionException( + failure: PilotRuntimeActionFailure.notEditableText, + message: 'Runtime Handle element-1 is not editable text.', + ), + ), + projectRoot: '/target/app', + ); + + await expectLater( + adapter.clearText(const FinderMatch(id: 'runtime-match-1')), + throwsA( + isA() + .having( + (RuntimeOperationException error) => error.operation, + 'operation', + RuntimeOperation.clearText, + ) + .having( + (RuntimeOperationException error) => error.message, + 'message', + 'Runtime Handle element-1 is not editable text.', + ), + ), + ); + }); + + test( + 'passes scroll handle and logical-pixel deltas to pilot_runtime', + () async { + final _FakePilotRuntimeClient client = _FakePilotRuntimeClient(); + final PilotRuntimeAdapter adapter = PilotRuntimeAdapter( + client: client, + projectRoot: '/target/app', + ); + + await adapter.performScroll( + match: const FinderMatch(id: 'runtime-match-1'), + deltaX: 12.5, + deltaY: -500, + ); + + expect(client.scrollRequests, <({String? handle, double dx, double dy})>[ + (handle: 'runtime-match-1', dx: 12.5, dy: -500), + ]); + }, + ); + + test('maps pilot_runtime scroll failures to action failures', () async { + final PilotRuntimeAdapter adapter = PilotRuntimeAdapter( + client: _FakePilotRuntimeClient( + scrollFailure: const PilotRuntimeActionException( + failure: PilotRuntimeActionFailure.notScrollable, + message: 'Runtime Handle element-1 does not identify a scrollable.', + ), + ), + projectRoot: '/target/app', + ); + + await expectLater( + adapter.performScroll( + match: const FinderMatch(id: 'runtime-match-1'), + deltaX: 0, + deltaY: -120, + ), + throwsA( + isA() + .having( + (RuntimeOperationException error) => error.operation, + 'operation', + RuntimeOperation.performScroll, + ) + .having( + (RuntimeOperationException error) => error.message, + 'message', + 'Runtime Handle element-1 does not identify a scrollable.', + ), + ), + ); + }); } class _FakePilotRuntimeClient implements PilotRuntimeClient { _FakePilotRuntimeClient({ this.initializeFailure, this.tapFailure, + this.clearTextFailure, + this.scrollFailure, Map? widgetTree, List? finderMatches, }) : widgetTree = widgetTree ?? {}, @@ -204,6 +308,8 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { final PilotRuntimeInitializationException? initializeFailure; final Object? tapFailure; + final Object? clearTextFailure; + final Object? scrollFailure; final Map widgetTree; final List finderMatches; final List projectRoots = []; @@ -213,6 +319,11 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { finderRequests = <({String? byText, String? byType, String? byKey, String? byWidget})>[]; final List tapHandles = []; + final List clearTextHandles = []; + final List<({String handle, String text})> enterTextRequests = + <({String handle, String text})>[]; + final List<({String? handle, double dx, double dy})> scrollRequests = + <({String? handle, double dx, double dy})>[]; @override Future initialize() async { @@ -223,6 +334,9 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { return const PilotRuntimeSession( protocolVersion: 1, capabilities: { + 'runtime.action.clearText', + 'runtime.action.enterText', + 'runtime.action.scroll', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -262,4 +376,31 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { } tapHandles.add(handle); } + + @override + Future clearText({required String handle}) async { + final Object? failure = clearTextFailure; + if (failure != null) { + throw failure; + } + clearTextHandles.add(handle); + } + + @override + Future enterText({required String handle, required String text}) async { + enterTextRequests.add((handle: handle, text: text)); + } + + @override + Future performScroll({ + String? handle, + required double deltaX, + required double deltaY, + }) async { + final Object? failure = scrollFailure; + if (failure != null) { + throw failure; + } + scrollRequests.add((handle: handle, dx: deltaX, dy: deltaY)); + } } diff --git a/test/runtime_adapter_selection_test.dart b/test/runtime_adapter_selection_test.dart index 79355e1..a30e5ce 100644 --- a/test/runtime_adapter_selection_test.dart +++ b/test/runtime_adapter_selection_test.dart @@ -3,7 +3,7 @@ import 'package:test/test.dart'; /// Verifies hidden Runtime Adapter selection without launching a Flutter app. void main() { - test('uses mcp_flutter adapter when hidden runtime switch is omitted', () { + test('uses PilotRuntimeAdapter when hidden runtime switch is omitted', () { final RuntimeAdapter adapter = RuntimeAdapterSelector.select( target: RuntimeTarget( vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), @@ -11,7 +11,7 @@ void main() { environment: const {}, ); - expect(adapter, isA()); + expect(adapter, isA()); }); test('uses PilotRuntimeAdapter when hidden runtime switch selects it', () { @@ -46,4 +46,24 @@ void main() { ), ); }); + + test('rejects retired mcp_flutter hidden runtime switch value', () { + expect( + () => RuntimeAdapterSelector.select( + target: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), + ), + environment: const { + RuntimeAdapterSelector.environmentKey: 'mcp_flutter', + }, + ), + throwsA( + isA().having( + (RuntimeAdapterSelectionException error) => error.message, + 'message', + contains('pilot_runtime'), + ), + ), + ); + }); } diff --git a/test/runtime_contract_test.dart b/test/runtime_contract_test.dart index be4d904..d215d66 100644 --- a/test/runtime_contract_test.dart +++ b/test/runtime_contract_test.dart @@ -7,8 +7,8 @@ import 'support/fake_runtime_adapter.dart'; /// Exercises the Runtime Adapter contract through the public package export. /// -/// These tests use a local fake implementation because Issue 1 defines the -/// runner-facing contract before any real `mcp_flutter` integration exists. +/// These tests use a local fake implementation so the runner-facing contract +/// stays independent of a live Runtime Target. void main() { group('RuntimeAdapter contract', () { test('represents the VM service URI Runtime Target', () { @@ -77,19 +77,22 @@ void main() { final FakeRuntimeAdapter adapter = FakeRuntimeAdapter(); await adapter.performTap(match); - await adapter.replaceText(match, 'bad@example.com'); + await adapter.clearText(match); + await adapter.enterText(match, 'b'); await adapter.performScroll(match: match, deltaX: 0, deltaY: -500); - expect(adapter.events, hasLength(3)); + expect(adapter.events, hasLength(4)); expect(adapter.events[0].operation, RuntimeOperation.performTap); expect(adapter.events[0].match, same(match)); - expect(adapter.events[1].operation, RuntimeOperation.replaceText); + expect(adapter.events[1].operation, RuntimeOperation.clearText); expect(adapter.events[1].match, same(match)); - expect(adapter.events[1].text, 'bad@example.com'); - expect(adapter.events[2].operation, RuntimeOperation.performScroll); + expect(adapter.events[2].operation, RuntimeOperation.enterText); expect(adapter.events[2].match, same(match)); - expect(adapter.events[2].deltaX, 0.0); - expect(adapter.events[2].deltaY, -500.0); + expect(adapter.events[2].text, 'b'); + expect(adapter.events[3].operation, RuntimeOperation.performScroll); + expect(adapter.events[3].match, same(match)); + expect(adapter.events[3].deltaX, 0.0); + expect(adapter.events[3].deltaY, -500.0); }); test('returns configured capture artifacts', () async { diff --git a/test/scenario_runner_test.dart b/test/scenario_runner_test.dart index af3c31b..a7997ab 100644 --- a/test/scenario_runner_test.dart +++ b/test/scenario_runner_test.dart @@ -94,13 +94,53 @@ void main() { RuntimeOperation.resolveFinder, RuntimeOperation.performTap, RuntimeOperation.resolveFinder, - RuntimeOperation.replaceText, + RuntimeOperation.clearText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, + RuntimeOperation.enterText, RuntimeOperation.resolveFinder, RuntimeOperation.performScroll, RuntimeOperation.resolveFinder, RuntimeOperation.dispose, ], ); + expect( + adapter.events + .where( + (FakeRuntimeEvent event) => + event.operation == RuntimeOperation.enterText, + ) + .map((FakeRuntimeEvent event) => event.text), + [ + 'b', + 'a', + 'd', + '@', + 'e', + 'x', + 'a', + 'm', + 'p', + 'l', + 'e', + '.', + 'c', + 'o', + 'm', + ], + ); }); }); diff --git a/test/smoke_scenario_contract_test.dart b/test/smoke_scenario_contract_test.dart index 0ea89ee..4a69cef 100644 --- a/test/smoke_scenario_contract_test.dart +++ b/test/smoke_scenario_contract_test.dart @@ -5,14 +5,20 @@ import 'package:test/test.dart'; /// the supported Finder contract. void main() { test( - 'example smoke Scenario includes byText, byType, combined Finder, and capture', + 'example smoke Scenario includes byText, byType, and combined Finder', () { final Scenario scenario = ScenarioParser.parseFile( - 'examples/smoke_scenario.yaml', + 'examples/smoke_app/smoke_scenario.yaml', ); expect(scenario.name, 'smoke_runtime'); + final WaitForAction waitForReady = _actionWithLabel( + scenario, + 'wait_for_smoke_form', + ); + expect(waitForReady.finder.byText, 'Smoke form'); + final TypeAction type = _actionWithLabel( scenario, 'enter_email', @@ -34,14 +40,7 @@ void main() { expect(waitFor.finder.byText, 'Smoke validation failed'); expect(waitFor.finder.byType, isNull); - final CaptureAction capture = _actionWithLabel( - scenario, - 'capture_runtime', - ); - expect(capture.screenshot, isTrue); - expect(capture.snapshot, isFalse); - expect(capture.logs, isTrue); - expect(capture.widgetTree, isTrue); + expect(scenario.steps, hasLength(4)); }, ); } diff --git a/test/smoke_script_test.dart b/test/smoke_script_test.dart index e2ab741..0831a98 100644 --- a/test/smoke_script_test.dart +++ b/test/smoke_script_test.dart @@ -4,22 +4,8 @@ import 'dart:io'; import 'package:flutter_pilot/src/smoke_verifier.dart'; import 'package:test/test.dart'; -/// Verifies the real-runtime smoke script command-line contract. -/// -/// The live Flutter app path is intentionally not part of default unit tests; -/// this test only checks that the script fails clearly before external runtime -/// work begins. +/// Verifies the real-runtime smoke report contract. void main() { - test('smoke script requires a VM service URI', () async { - final ProcessResult result = await Process.run( - Platform.resolvedExecutable, - ['run', 'tool/run_mcp_flutter_smoke.dart'], - ); - - expect(result.exitCode, 64); - expect(result.stderr, contains('Usage:')); - }); - group('SmokeRunVerifier', () { test('extracts the run report path from Flutter Pilot stdout', () { final String? reportPath = SmokeRunVerifier.runReportPathFromStdout(''' @@ -33,7 +19,7 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html ); }); - test('passes when Finder Steps and capture artifacts are valid', () { + test('passes when required smoke Steps are valid', () { final Directory runDirectory = Directory.systemTemp.createTempSync( 'flutter_pilot_smoke_report_', ); @@ -42,12 +28,11 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html runDirectory: runDirectory, status: 'passed', stepStatuses: const { + 'wait_for_smoke_form': 'passed', 'enter_email': 'passed', 'submit_form': 'passed', 'wait_for_error': 'passed', - 'capture_runtime': 'passed', }, - createArtifactFiles: true, ); final SmokeVerificationResult result = @@ -69,12 +54,11 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html runDirectory: runDirectory, status: 'failed', stepStatuses: const { + 'wait_for_smoke_form': 'passed', 'enter_email': 'passed', 'submit_form': 'failed', 'wait_for_error': 'skipped', - 'capture_runtime': 'skipped', }, - createArtifactFiles: true, ); final SmokeVerificationResult result = @@ -94,7 +78,7 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html } }); - test('fails when a required capture artifact file is missing', () { + test('fails when a required smoke Step is missing', () { final Directory runDirectory = Directory.systemTemp.createTempSync( 'flutter_pilot_smoke_report_', ); @@ -103,25 +87,17 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html runDirectory: runDirectory, status: 'passed', stepStatuses: const { + 'wait_for_smoke_form': 'passed', 'enter_email': 'passed', 'submit_form': 'passed', - 'wait_for_error': 'passed', - 'capture_runtime': 'passed', }, - createArtifactFiles: false, ); final SmokeVerificationResult result = SmokeRunVerifier.verifyReportFile(reportFile); expect(result.passed, isFalse); - expect( - result.errors, - contains( - 'Missing capture artifact file for screenshot: ' - '${runDirectory.path}/captures/0005_capture_runtime_screenshot.png', - ), - ); + expect(result.errors, contains('Missing Finder Step: wait_for_error.')); } finally { runDirectory.deleteSync(recursive: true); } @@ -134,34 +110,7 @@ File _writeSmokeReport({ required Directory runDirectory, required String status, required Map stepStatuses, - required bool createArtifactFiles, }) { - final List> captureArtifacts = >[ - { - 'type': 'screenshot', - 'path': 'captures/0005_capture_runtime_screenshot.png', - 'purpose': 'capture', - }, - { - 'type': 'widgetTree', - 'path': 'captures/0005_capture_runtime_widget_tree.json', - 'purpose': 'capture', - }, - { - 'type': 'logs', - 'path': 'captures/0005_capture_runtime_logs.json', - 'purpose': 'capture', - }, - ]; - if (createArtifactFiles) { - for (final Map artifact in captureArtifacts) { - final String path = artifact['path']! as String; - final File artifactFile = File('${runDirectory.path}/$path'); - artifactFile.parent.createSync(recursive: true); - artifactFile.writeAsStringSync('{}'); - } - } - final File reportFile = File('${runDirectory.path}/run_report.json'); reportFile.writeAsStringSync( const JsonEncoder.withIndent(' ').convert({ @@ -172,21 +121,22 @@ File _writeSmokeReport({ 'runDirectory': runDirectory.path, 'artifacts': const [], 'steps': [ - _stepJson(label: 'enter_email', action: 'type', status: stepStatuses), - _stepJson(label: 'submit_form', action: 'tap', status: stepStatuses), - _stepJson( - label: 'wait_for_error', - action: 'waitFor', - status: stepStatuses, - ), - { - ..._stepJson( - label: 'capture_runtime', - action: 'capture', + if (stepStatuses.containsKey('wait_for_smoke_form')) + _stepJson( + label: 'wait_for_smoke_form', + action: 'waitFor', + status: stepStatuses, + ), + if (stepStatuses.containsKey('enter_email')) + _stepJson(label: 'enter_email', action: 'type', status: stepStatuses), + if (stepStatuses.containsKey('submit_form')) + _stepJson(label: 'submit_form', action: 'tap', status: stepStatuses), + if (stepStatuses.containsKey('wait_for_error')) + _stepJson( + label: 'wait_for_error', + action: 'waitFor', status: stepStatuses, ), - 'artifacts': captureArtifacts, - }, ], }), ); diff --git a/test/support/fake_runtime_adapter.dart b/test/support/fake_runtime_adapter.dart index 600334b..aea0a49 100644 --- a/test/support/fake_runtime_adapter.dart +++ b/test/support/fake_runtime_adapter.dart @@ -88,11 +88,19 @@ class FakeRuntimeAdapter implements RuntimeAdapter { } @override - Future replaceText(FinderMatch match, String text) async { - _throwIfConfigured(RuntimeOperation.replaceText); + Future clearText(FinderMatch match) async { + _throwIfConfigured(RuntimeOperation.clearText); + events.add( + FakeRuntimeEvent(operation: RuntimeOperation.clearText, match: match), + ); + } + + @override + Future enterText(FinderMatch match, String text) async { + _throwIfConfigured(RuntimeOperation.enterText); events.add( FakeRuntimeEvent( - operation: RuntimeOperation.replaceText, + operation: RuntimeOperation.enterText, match: match, text: text, ), diff --git a/tool/run_mcp_flutter_smoke.dart b/tool/run_mcp_flutter_smoke.dart deleted file mode 100644 index 1efffb8..0000000 --- a/tool/run_mcp_flutter_smoke.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_pilot/src/smoke_verifier.dart'; - -/// Run the real Flutter Pilot smoke path against a running sample app. -/// -/// Usage: -/// `dart run tool/run_mcp_flutter_smoke.dart ws://127.0.0.1:1234/token=/ws` -/// -/// The script expects `examples/smoke_app` to already be running in debug mode. -/// It validates the Runtime Target with `flutter-mcp-toolkit`, then runs the -/// smoke Scenario through the Flutter Pilot CLI and verifies the produced -/// `run_report.json` for CI-friendly pass/fail output. -Future main(List arguments) async { - if (arguments.length != 1) { - stderr.writeln( - 'Usage: dart run tool/run_mcp_flutter_smoke.dart ', - ); - exitCode = SmokeVerifierExitCodes.usage; - return; - } - - final String target = arguments.single; - final Directory smokeAppDirectory = Directory('examples/smoke_app'); - if (!smokeAppDirectory.existsSync()) { - stderr.writeln( - 'Missing examples/smoke_app. Run this script from repo root.', - ); - exitCode = SmokeVerifierExitCodes.usage; - return; - } - - final ProcessResult validationResult = - await _runProcess('flutter-mcp-toolkit', [ - '--flutter-project-dir', - smokeAppDirectory.path, - 'validate-runtime', - '--target', - target, - '--timeout-ms', - '10000', - ]); - if (validationResult.exitCode != 0) { - stderr.writeln( - 'Runtime validation failed with exit code ' - '${validationResult.exitCode}.', - ); - exitCode = SmokeVerifierExitCodes.runtimeValidationFailed; - return; - } - - final ProcessResult runResult = - await _runProcess(Platform.resolvedExecutable, [ - 'run', - 'bin/flutter_pilot.dart', - 'run', - 'examples/smoke_scenario.yaml', - '--target', - target, - ]); - final String? runReportPath = SmokeRunVerifier.runReportPathFromStdout( - runResult.stdout.toString(), - ); - if (runReportPath == null) { - stderr.writeln('Could not find "Run report:" in Flutter Pilot output.'); - exitCode = SmokeVerifierExitCodes.missingReport; - return; - } - - final SmokeVerificationResult verification = - SmokeRunVerifier.verifyReportFile(File(runReportPath)); - if (!verification.passed) { - stderr.writeln('Smoke verification failed:'); - for (final String error in verification.errors) { - stderr.writeln('- $error'); - } - exitCode = runResult.exitCode == 0 - ? SmokeVerifierExitCodes.reportVerificationFailed - : SmokeVerifierExitCodes.scenarioRunFailed; - return; - } - - if (runResult.exitCode != 0) { - stderr.writeln( - 'Flutter Pilot run failed with exit code ${runResult.exitCode}.', - ); - exitCode = SmokeVerifierExitCodes.scenarioRunFailed; - return; - } - - stdout.writeln('Smoke verification passed: ${verification.reportPath}'); -} - -/// Run a child process, then forward its stdout and stderr. -/// -/// Args: -/// `executable` is the command to start. -/// `arguments` are passed as separate process arguments. -/// -/// Returns: -/// The completed process result so the verifier can inspect stdout. -Future _runProcess( - String executable, - List arguments, -) async { - stdout.writeln('\$ $executable ${arguments.join(' ')}'); - final ProcessResult result = await Process.run(executable, arguments); - stdout.write(result.stdout); - stderr.write(result.stderr); - return result; -}