From 5a38756c0daa6daa6f488e27abf61b01a8e6e9ae Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 20:21:13 +0800 Subject: [PATCH 01/15] feat(runtime): default to pilot_runtime --- README.md | 33 +- README_zh.md | 31 +- ...001-use-dart-cli-with-yaml-scenario-dsl.md | 2 +- docs-internal/flutter-pilot-prd.md | 39 +- docs-internal/pilot-runtime-prd.md | 8 +- examples/smoke_app/README.md | 29 +- examples/smoke_app/lib/main.dart | 14 +- .../lib/pilot_runtime_probe_app.dart | 2 +- examples/smoke_app/pubspec.lock | 72 --- examples/smoke_app/pubspec.yaml | 1 - examples/{ => smoke_app}/smoke_scenario.yaml | 14 +- issues/0.1.1-pilot-runtime.md | 13 +- lib/flutter_pilot.dart | 1 - lib/src/app_setup.dart | 73 +-- lib/src/commands/app_setup_commands.dart | 50 +- .../runtime/mcp_flutter_runtime_adapter.dart | 486 ------------------ lib/src/runtime/pilot_runtime_adapter.dart | 39 +- lib/src/runtime/runtime_adapter_selector.dart | 14 +- lib/src/runtime/runtime_contract.dart | 26 +- lib/src/scenario_runner.dart | 14 +- lib/src/smoke_verifier.dart | 90 +--- lib/src/test_scenario_runner_factory.dart | 2 +- .../lib/src/finder_resolver.dart | 8 +- .../lib/src/pilot_runtime_binding.dart | 20 + .../lib/src/pilot_runtime_client.dart | 33 ++ .../lib/src/pilot_runtime_protocol.dart | 19 +- .../pilot_runtime/lib/src/text_performer.dart | 85 +++ .../test/pilot_runtime_binding_test.dart | 4 + .../test/pilot_runtime_client_test.dart | 64 +++ .../test/pilot_runtime_finder_test.dart | 128 +++++ test/app_setup_test.dart | 46 +- test/cli_test.dart | 39 +- test/mcp_flutter_runtime_adapter_test.dart | 260 ---------- test/pilot_runtime_adapter_test.dart | 69 +++ test/runtime_adapter_selection_test.dart | 24 +- test/runtime_contract_test.dart | 21 +- test/scenario_runner_test.dart | 42 +- test/smoke_scenario_contract_test.dart | 19 +- test/smoke_script_test.dart | 92 +--- test/support/fake_runtime_adapter.dart | 14 +- tool/run_mcp_flutter_smoke.dart | 111 ---- 41 files changed, 807 insertions(+), 1344 deletions(-) rename examples/{ => smoke_app}/smoke_scenario.yaml (76%) delete mode 100644 lib/src/runtime/mcp_flutter_runtime_adapter.dart create mode 100644 packages/pilot_runtime/lib/src/text_performer.dart delete mode 100644 test/mcp_flutter_runtime_adapter_test.dart delete mode 100644 tool/run_mcp_flutter_smoke.dart 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..41500b0 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 @@ -69,18 +65,3 @@ Expected result: - the Scenario fails at `tap_read_only_text` - the failure reason contains `cannot be tapped` - failure diagnostics are still written under the run directory - -## Optional CI - -The real `mcp_flutter` smoke path is available as an opt-in GitHub Actions -workflow: - -```text -Real mcp_flutter Smoke -``` - -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. 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/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..ebaaba0 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 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..0282ec9 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 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..2988487 100644 --- a/packages/pilot_runtime/lib/src/finder_resolver.dart +++ b/packages/pilot_runtime/lib/src/finder_resolver.dart @@ -182,7 +182,13 @@ class PilotRuntimeFinderResolver { 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 || diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart index 109037d..6577369 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'finder_resolver.dart'; import 'pilot_runtime_protocol.dart'; import 'tap_performer.dart'; +import 'text_performer.dart'; /// Handles one app-side Flutter Pilot service extension request. /// @@ -63,6 +64,8 @@ class PilotRuntimeBinding { _handleResolveFinder, ); registrar(PilotRuntimeProtocol.tapExtension, _handleTap); + registrar(PilotRuntimeProtocol.clearTextExtension, _handleClearText); + registrar(PilotRuntimeProtocol.enterTextExtension, _handleEnterText); _initialized = true; } @@ -100,6 +103,23 @@ 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 String? _optionalString( Map parameters, String field, diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart index 9f31ec3..cd5bb0a 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart @@ -140,6 +140,9 @@ enum PilotRuntimeActionFailure { /// A resolved Runtime Handle cannot receive the requested tap action. notTappable, + /// A resolved Runtime Handle cannot receive editable text actions. + notEditableText, + /// The app-side runtime returned an action failure code this client does not /// understand yet. unknown, @@ -517,6 +520,35 @@ 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'); + } + void _checkActionResponse( Map response, { required String actionName, @@ -539,6 +571,7 @@ class PilotRuntimeClient { final Object? codeValue = response['code']; final PilotRuntimeActionFailure failure = switch (codeValue) { 'notTappable' => PilotRuntimeActionFailure.notTappable, + 'notEditableText' => PilotRuntimeActionFailure.notEditableText, _ => 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..d9ea894 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, and +/// editable text entry. class PilotRuntimeProtocol { PilotRuntimeProtocol._(); @@ -18,6 +19,14 @@ 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'; + /// Capability name reported when the handshake extension is available. static const String handshakeCapability = 'runtime.handshake'; @@ -27,11 +36,19 @@ 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'; + /// Capabilities that this client requires before Scenario execution. static const Set requiredCapabilities = { handshakeCapability, resolveFinderCapability, tapCapability, + clearTextCapability, + enterTextCapability, }; } 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..65d0ca4 100644 --- a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart @@ -24,6 +24,8 @@ void main() { PilotRuntimeProtocol.handshakeExtension, PilotRuntimeProtocol.resolveFinderExtension, PilotRuntimeProtocol.tapExtension, + PilotRuntimeProtocol.clearTextExtension, + PilotRuntimeProtocol.enterTextExtension, ]); expect( await registeredHandlers[PilotRuntimeProtocol.handshakeExtension]!( @@ -32,6 +34,8 @@ void main() { { 'protocolVersion': 1, 'capabilities': [ + 'runtime.action.clearText', + 'runtime.action.enterText', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', diff --git a/packages/pilot_runtime/test/pilot_runtime_client_test.dart b/packages/pilot_runtime/test/pilot_runtime_client_test.dart index ab05829..d7baf99 100644 --- a/packages/pilot_runtime/test/pilot_runtime_client_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_client_test.dart @@ -9,6 +9,8 @@ void main() { handshakeResponse: { 'protocolVersion': 1, 'capabilities': [ + 'runtime.action.clearText', + 'runtime.action.enterText', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -23,6 +25,8 @@ 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(vmService.calledExtensions, [ PilotRuntimeProtocol.handshakeExtension, ]); @@ -84,6 +88,8 @@ void main() { handshakeResponse: { 'protocolVersion': 2, 'capabilities': [ + 'runtime.action.clearText', + 'runtime.action.enterText', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -219,6 +225,64 @@ 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.', + ), + ), + ); + }); + }); } 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..e9013ab 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 { @@ -415,6 +449,80 @@ 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')); + }); }); } @@ -463,6 +571,26 @@ 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, + }); +} + 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..c0184e4 100644 --- a/test/pilot_runtime_adapter_test.dart +++ b/test/pilot_runtime_adapter_test.dart @@ -191,12 +191,61 @@ 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.', + ), + ), + ); + }); } class _FakePilotRuntimeClient implements PilotRuntimeClient { _FakePilotRuntimeClient({ this.initializeFailure, this.tapFailure, + this.clearTextFailure, Map? widgetTree, List? finderMatches, }) : widgetTree = widgetTree ?? {}, @@ -204,6 +253,7 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { final PilotRuntimeInitializationException? initializeFailure; final Object? tapFailure; + final Object? clearTextFailure; final Map widgetTree; final List finderMatches; final List projectRoots = []; @@ -213,6 +263,9 @@ 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})>[]; @override Future initialize() async { @@ -223,6 +276,8 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { return const PilotRuntimeSession( protocolVersion: 1, capabilities: { + 'runtime.action.clearText', + 'runtime.action.enterText', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -262,4 +317,18 @@ 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)); + } } 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; -} From 3792e7eec69ba69940b0c11cb059402c457fa026 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 20:56:15 +0800 Subject: [PATCH 02/15] feat(runtime): implement pilot runtime scroll replay --- examples/smoke_app/README.md | 40 +++++ .../lib/pilot_runtime_scroll_demo.dart | 74 ++++++++ .../smoke_app/pilot/pilot_runtime_scroll.yaml | 16 ++ .../pilot_runtime_scroll_non_scrollable.yaml | 11 ++ lib/src/runtime/pilot_runtime_adapter.dart | 22 ++- .../lib/src/finder_resolver.dart | 13 ++ .../lib/src/pilot_runtime_binding.dart | 27 +++ .../lib/src/pilot_runtime_client.dart | 35 ++++ .../lib/src/pilot_runtime_protocol.dart | 11 +- .../lib/src/scroll_performer.dart | 165 ++++++++++++++++++ .../test/pilot_runtime_binding_test.dart | 2 + .../test/pilot_runtime_client_test.dart | 36 ++++ .../test/pilot_runtime_finder_test.dart | 158 +++++++++++++++++ test/pilot_runtime_adapter_test.dart | 72 ++++++++ 14 files changed, 678 insertions(+), 4 deletions(-) create mode 100644 examples/smoke_app/lib/pilot_runtime_scroll_demo.dart create mode 100644 examples/smoke_app/pilot/pilot_runtime_scroll.yaml create mode 100644 examples/smoke_app/pilot/pilot_runtime_scroll_non_scrollable.yaml create mode 100644 packages/pilot_runtime/lib/src/scroll_performer.dart diff --git a/examples/smoke_app/README.md b/examples/smoke_app/README.md index 41500b0..cb8498e 100644 --- a/examples/smoke_app/README.md +++ b/examples/smoke_app/README.md @@ -65,3 +65,43 @@ Expected result: - the Scenario fails at `tap_read_only_text` - the failure reason contains `cannot be tapped` - failure diagnostics are still written under the run directory + +## PilotRuntimeAdapter Scroll Acceptance + +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 +``` + +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 +``` + +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/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/lib/src/runtime/pilot_runtime_adapter.dart b/lib/src/runtime/pilot_runtime_adapter.dart index 0282ec9..1c07ea1 100644 --- a/lib/src/runtime/pilot_runtime_adapter.dart +++ b/lib/src/runtime/pilot_runtime_adapter.dart @@ -130,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/packages/pilot_runtime/lib/src/finder_resolver.dart b/packages/pilot_runtime/lib/src/finder_resolver.dart index 2988487..1e3721c 100644 --- a/packages/pilot_runtime/lib/src/finder_resolver.dart +++ b/packages/pilot_runtime/lib/src/finder_resolver.dart @@ -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, diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart index 6577369..9d88d38 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart @@ -6,6 +6,7 @@ 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'; @@ -66,6 +67,7 @@ class PilotRuntimeBinding { registrar(PilotRuntimeProtocol.tapExtension, _handleTap); registrar(PilotRuntimeProtocol.clearTextExtension, _handleClearText); registrar(PilotRuntimeProtocol.enterTextExtension, _handleEnterText); + registrar(PilotRuntimeProtocol.scrollExtension, _handleScroll); _initialized = true; } @@ -120,6 +122,16 @@ class PilotRuntimeBinding { ); } + 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, @@ -146,6 +158,21 @@ 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; + } + 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 cd5bb0a..e187189 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart @@ -143,6 +143,12 @@ enum PilotRuntimeActionFailure { /// 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, @@ -549,6 +555,32 @@ class PilotRuntimeClient { _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, @@ -572,6 +604,9 @@ class PilotRuntimeClient { 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 d9ea894..d496d29 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_protocol.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_protocol.dart @@ -1,7 +1,7 @@ /// Protocol constants shared by the app-side hook and VM Service client. /// -/// Version 1 exposes runtime handshake, visible Finder resolution, tap, and -/// editable text entry. +/// Version 1 exposes runtime handshake, visible Finder resolution, tap, +/// editable text entry, and scroll replay. class PilotRuntimeProtocol { PilotRuntimeProtocol._(); @@ -27,6 +27,9 @@ class PilotRuntimeProtocol { 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'; @@ -42,6 +45,9 @@ class PilotRuntimeProtocol { /// 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, @@ -49,6 +55,7 @@ class PilotRuntimeProtocol { 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/test/pilot_runtime_binding_test.dart b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart index 65d0ca4..b147ef1 100644 --- a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart @@ -26,6 +26,7 @@ void main() { PilotRuntimeProtocol.tapExtension, PilotRuntimeProtocol.clearTextExtension, PilotRuntimeProtocol.enterTextExtension, + PilotRuntimeProtocol.scrollExtension, ]); expect( await registeredHandlers[PilotRuntimeProtocol.handshakeExtension]!( @@ -36,6 +37,7 @@ void main() { 'capabilities': [ 'runtime.action.clearText', 'runtime.action.enterText', + 'runtime.action.scroll', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', diff --git a/packages/pilot_runtime/test/pilot_runtime_client_test.dart b/packages/pilot_runtime/test/pilot_runtime_client_test.dart index d7baf99..8f7f735 100644 --- a/packages/pilot_runtime/test/pilot_runtime_client_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_client_test.dart @@ -11,6 +11,7 @@ void main() { 'capabilities': [ 'runtime.action.clearText', 'runtime.action.enterText', + 'runtime.action.scroll', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -27,6 +28,7 @@ void main() { 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, ]); @@ -283,6 +285,40 @@ void main() { ); }); }); + + 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 e9013ab..65321a7 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -523,6 +523,148 @@ void main() { 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')); + }); }); } @@ -591,6 +733,22 @@ Future> _enterText( }); } +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/pilot_runtime_adapter_test.dart b/test/pilot_runtime_adapter_test.dart index c0184e4..e379d9c 100644 --- a/test/pilot_runtime_adapter_test.dart +++ b/test/pilot_runtime_adapter_test.dart @@ -239,6 +239,60 @@ void main() { ), ); }); + + 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 { @@ -246,6 +300,7 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { this.initializeFailure, this.tapFailure, this.clearTextFailure, + this.scrollFailure, Map? widgetTree, List? finderMatches, }) : widgetTree = widgetTree ?? {}, @@ -254,6 +309,7 @@ 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 = []; @@ -266,6 +322,8 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { 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 { @@ -278,6 +336,7 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { capabilities: { 'runtime.action.clearText', 'runtime.action.enterText', + 'runtime.action.scroll', 'runtime.action.tap', 'runtime.finder.resolve', 'runtime.handshake', @@ -331,4 +390,17 @@ class _FakePilotRuntimeClient implements PilotRuntimeClient { 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)); + } } From 5de4b87d5ecbce990d0d03e6c5411875b7332aa4 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 21:47:44 +0800 Subject: [PATCH 03/15] fix(runtime): resolve keyed scrollable finders --- issues/0.1.1-pilot-runtime.md | 27 ++++++++++++---- .../lib/src/finder_resolver.dart | 23 ++++++++++++-- .../test/pilot_runtime_finder_test.dart | 31 +++++++++++++++++++ 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/issues/0.1.1-pilot-runtime.md b/issues/0.1.1-pilot-runtime.md index ebaaba0..6608339 100644 --- a/issues/0.1.1-pilot-runtime.md +++ b/issues/0.1.1-pilot-runtime.md @@ -265,6 +265,10 @@ Implement text entry through `pilot_runtime` and `PilotRuntimeAdapter`. A Scenar ## 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 @@ -275,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/packages/pilot_runtime/lib/src/finder_resolver.dart b/packages/pilot_runtime/lib/src/finder_resolver.dart index 1e3721c..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(), @@ -125,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) { @@ -154,6 +158,7 @@ class PilotRuntimeFinderResolver { ownText: ownText, descendantText: descendantText, semanticType: semanticType, + descendantSemanticType: _descendantSemanticTypeFor(element), valueKey: _valueKeyFor(element), widgetType: element.widget.runtimeType.toString(), ); @@ -193,6 +198,15 @@ 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 TextField) { @@ -301,6 +315,7 @@ class _FinderEvidence { this.ownText, this.descendantText, this.semanticType, + this.descendantSemanticType, this.valueKey, this.widgetType, }); @@ -308,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/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index 65321a7..1a664c0 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -219,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 { From 95c50d75a4b6504e07d5b121ab8676485c273090 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 22:31:32 +0800 Subject: [PATCH 04/15] no-mistakes(review): Fix runtime scroll and adapter selection --- lib/flutter_pilot.dart | 1 + .../runtime/mcp_flutter_runtime_adapter.dart | 415 ++++++++++++++++++ lib/src/runtime/runtime_adapter_selector.dart | 10 +- .../lib/src/pilot_runtime_binding.dart | 6 + .../lib/src/scroll_performer.dart | 20 +- .../test/pilot_runtime_binding_test.dart | 34 ++ .../test/pilot_runtime_finder_test.dart | 45 ++ test/mcp_flutter_runtime_adapter_test.dart | 85 ++++ test/runtime_adapter_selection_test.dart | 57 ++- 9 files changed, 633 insertions(+), 40 deletions(-) create mode 100644 lib/src/runtime/mcp_flutter_runtime_adapter.dart create mode 100644 test/mcp_flutter_runtime_adapter_test.dart diff --git a/lib/flutter_pilot.dart b/lib/flutter_pilot.dart index df07aa1..f8df0da 100644 --- a/lib/flutter_pilot.dart +++ b/lib/flutter_pilot.dart @@ -15,6 +15,7 @@ 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/runtime/mcp_flutter_runtime_adapter.dart b/lib/src/runtime/mcp_flutter_runtime_adapter.dart new file mode 100644 index 0000000..c97f9e4 --- /dev/null +++ b/lib/src/runtime/mcp_flutter_runtime_adapter.dart @@ -0,0 +1,415 @@ +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 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 { + McpFlutterRuntimeAdapter({ + required this.target, + this.commandRunner = defaultCommandRunner, + }); + + final RuntimeTarget target; + final McpFlutterCommandRunner commandRunner; + final Map _enteredTextByHandle = {}; + + /// 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 clearText(FinderMatch match) async { + _enteredTextByHandle[match.id] = ''; + await _execute(RuntimeOperation.clearText, 'enter_text', { + 'ref': match.id, + 'text': '', + }); + } + + @override + Future enterText(FinderMatch match, String text) async { + final String nextText = (_enteredTextByHandle[match.id] ?? '') + text; + _enteredTextByHandle[match.id] = nextText; + await _execute(RuntimeOperation.enterText, 'enter_text', { + 'ref': match.id, + 'text': nextText, + }); + } + + @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', + ); + } + + @override + Future captureSnapshot() async { + return SnapshotCapture( + data: await _captureSemanticSnapshot(RuntimeOperation.captureSnapshot), + ); + } + + @override + Future captureWidgetTree() async { + try { + return WidgetTreeCapture( + data: await _captureSemanticSnapshot( + RuntimeOperation.captureWidgetTree, + ), + ); + } on RuntimeOperationException catch (error) { + throw RuntimeOperationException( + operation: RuntimeOperation.captureWidgetTree, + message: error.message, + cause: error.cause, + rawOutput: error.rawOutput, + ); + } + } + + @override + Future collectLogs() async { + final Object? data = await _execute( + RuntimeOperation.collectLogs, + 'get_app_errors', + const {}, + ); + return LogsCapture(data: data ?? const {}); + } + + 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('; '), + ); + } + + Future _captureSemanticSnapshot(RuntimeOperation operation) async { + final Object? data = await _execute( + operation, + 'semantic_snapshot', + const {}, + ); + return data ?? const {}; + } + + Future _execute( + RuntimeOperation operation, + String name, + Map arguments, + ) async { + final Map response = await commandRunner( + McpFlutterCommandCall( + name: name, + arguments: { + ...arguments, + 'connection': { + 'mode': 'uri', + 'uri': target.vmServiceUri.toString(), + }, + }, + ), + ); + final Object? ok = response['ok']; + if (ok != true) { + throw RuntimeOperationException( + operation: operation, + message: + _errorMessage(response) ?? 'flutter-mcp-toolkit command failed.', + rawOutput: response, + ); + } + return response['data']; + } + + static String? _errorMessage(Map response) { + final Object? error = response['error']; + if (error is Map) { + final Object? message = error['message']; + if (message is String && message.isNotEmpty) { + return message; + } + } + return null; + } + + static List> _snapshotNodes(Object? data) { + if (data is! Map) { + return const >[]; + } + final Object? nodes = data['nodes']; + if (nodes is! List) { + return const >[]; + } + return >[ + for (final Object? node in nodes) + if (node is Map) node, + ]; + } + + static bool _matchesFinder(Map node, Finder finder) { + return _matchesString(node['label'], finder.byText) && + _matchesString(node['type'], finder.byType) && + _matchesString(node['key'], finder.byKey) && + _matchesString(node['widgetType'], finder.byWidget); + } + + static bool _matchesString(Object? actual, String? expected) { + return expected == null || actual == expected; + } + + static FinderMatch _matchFromNode(Map node) { + final Object? ref = node['ref']; + return FinderMatch( + id: ref is String ? ref : '', + debugLabel: node['label'] as String?, + text: node['label'] as String?, + key: node['key'] as String?, + type: node['type'] as String?, + bounds: _boundsFromNode(node['rect']), + ); + } + + static WidgetBounds? _boundsFromNode(Object? value) { + if (value is! Map) { + return null; + } + final double? left = _numberToDouble(value['left']); + final double? top = _numberToDouble(value['top']); + final double? width = _numberToDouble(value['width']); + final double? height = _numberToDouble(value['height']); + if (left == null || top == null || width == null || height == null) { + return null; + } + return WidgetBounds(left: left, top: top, width: width, height: height); + } + + static double? _numberToDouble(Object? value) { + if (value is int) { + return value.toDouble(); + } + if (value is double) { + return value; + } + return null; + } + + static String _scrollDirection({ + required double deltaX, + required double deltaY, + }) { + if (deltaY.abs() >= deltaX.abs()) { + return deltaY < 0 ? 'down' : 'up'; + } + return deltaX < 0 ? 'right' : 'left'; + } + + static double _scrollDistance({ + required double deltaX, + required double deltaY, + }) { + return deltaY.abs() >= deltaX.abs() ? deltaY.abs() : deltaX.abs(); + } + + static String? _firstScreenshotImage(Object? data) { + if (data is Map) { + final Object? images = data['images']; + if (images is List && images.isNotEmpty) { + final Object? image = images.first; + return image is String ? image : null; + } + final Object? image = data['image']; + if (image is String) { + return image; + } + final Object? screenshot = data['screenshot']; + if (screenshot is String) { + return screenshot; + } + } + return null; + } +} + +RuntimeOperation _operationForCommand(String command) { + return switch (command) { + 'semantic_snapshot' => RuntimeOperation.captureSnapshot, + 'tap_widget' => RuntimeOperation.performTap, + 'enter_text' => RuntimeOperation.enterText, + 'scroll' => RuntimeOperation.performScroll, + 'get_screenshots' || + 'capture_ui_snapshot' => RuntimeOperation.captureScreenshot, + 'get_app_errors' => RuntimeOperation.collectLogs, + 'get_extension_rpcs' => RuntimeOperation.initialize, + _ => RuntimeOperation.initialize, + }; +} diff --git a/lib/src/runtime/runtime_adapter_selector.dart b/lib/src/runtime/runtime_adapter_selector.dart index 89d7ace..22972d1 100644 --- a/lib/src/runtime/runtime_adapter_selector.dart +++ b/lib/src/runtime/runtime_adapter_selector.dart @@ -2,13 +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 uses `pilot_runtime`. A hidden environment switch remains +/// The default path stays on `mcp_flutter`. A hidden environment switch remains /// available for explicit runtime selection without adding public CLI flags. class RuntimeAdapterSelector { RuntimeAdapterSelector._(); @@ -30,7 +31,10 @@ class RuntimeAdapterSelector { final Map effectiveEnvironment = environment ?? Platform.environment; final String runtime = effectiveEnvironment[environmentKey] ?? ''; - if (runtime.isEmpty || runtime == 'pilot_runtime') { + if (runtime.isEmpty || runtime == 'mcp_flutter') { + return McpFlutterRuntimeAdapter(target: target); + } + if (runtime == 'pilot_runtime') { final PilotRuntimeVmServiceConnection vmService = PilotRuntimeVmServiceConnection(vmServiceUri: target.vmServiceUri); return PilotRuntimeAdapter( @@ -41,7 +45,7 @@ class RuntimeAdapterSelector { } throw RuntimeAdapterSelectionException( 'Invalid $environmentKey value "$runtime". ' - 'Expected "pilot_runtime".', + 'Expected "mcp_flutter" or "pilot_runtime".', ); } } diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart index 9d88d38..63703d3 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart @@ -170,6 +170,12 @@ class PilotRuntimeBinding { if (value is double) { return value; } + if (value is String) { + final double? parsedValue = double.tryParse(value); + if (parsedValue != null) { + return parsedValue; + } + } throw FormatException('$operation parameter $field must be a number.'); } diff --git a/packages/pilot_runtime/lib/src/scroll_performer.dart b/packages/pilot_runtime/lib/src/scroll_performer.dart index e11dd3e..6bf5640 100644 --- a/packages/pilot_runtime/lib/src/scroll_performer.dart +++ b/packages/pilot_runtime/lib/src/scroll_performer.dart @@ -114,22 +114,26 @@ class PilotRuntimeScrollPerformer { if (element.widget is Scrollable) { return element; } - Element? scrollable; + final List scrollables = []; element.visitChildren((Element child) { - scrollable ??= _firstScrollableInSubtree(child); + _collectScrollablesInSubtree(child, scrollables); }); - return scrollable; + if (scrollables.length != 1) { + return null; + } + return scrollables.single; } - static Element? _firstScrollableInSubtree(Element element) { + static void _collectScrollablesInSubtree( + Element element, + List scrollables, + ) { if (element.widget is Scrollable) { - return element; + scrollables.add(element); } - Element? scrollable; element.visitChildren((Element child) { - scrollable ??= _firstScrollableInSubtree(child); + _collectScrollablesInSubtree(child, scrollables); }); - return scrollable; } static Element? _primaryScrollable() { diff --git a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart index b147ef1..fbc4fa4 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/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pilot_runtime/pilot_runtime.dart'; @@ -59,5 +60,38 @@ void main() { expect(registeredExtensions, isEmpty); }); + + testWidgets('accepts VM service string parameters for scroll deltas', ( + WidgetTester tester, + ) async { + final Map registeredHandlers = + {}; + PilotRuntimeBinding.ensureInitialized( + debugMode: true, + registerExtension: + (String extensionName, PilotRuntimeExtensionHandler handler) { + registeredHandlers[extensionName] = handler; + }, + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + children: const [ + SizedBox(height: 2000, child: Text('Long content')), + ], + ), + ), + ), + ); + + final Map response = + await registeredHandlers[PilotRuntimeProtocol.scrollExtension]!( + const {'deltaX': '0', 'deltaY': '-120.5'}, + ); + + expect(response['ok'], isTrue); + expect(response['method'], 'pointer'); + }); }); } diff --git a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index 1a664c0..fcb369e 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -625,6 +625,51 @@ void main() { expect(scrollResponse['message'], contains('scrollable')); }); + testWidgets('scroll fails for a handle with multiple scrollable children', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + key: const ValueKey('scroll_group'), + children: [ + Expanded( + child: ListView( + children: const [Text('Left'), Text('Left 2')], + ), + ), + Expanded( + child: ListView( + children: const [Text('Right'), Text('Right 2')], + ), + ), + ], + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byKey: 'scroll_group', + ); + 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 { diff --git a/test/mcp_flutter_runtime_adapter_test.dart b/test/mcp_flutter_runtime_adapter_test.dart new file mode 100644 index 0000000..7e90d25 --- /dev/null +++ b/test/mcp_flutter_runtime_adapter_test.dart @@ -0,0 +1,85 @@ +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( + 'captures screenshots, Widget Tree, 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 WidgetTreeCapture widgetTree = await adapter.captureWidgetTree(); + final LogsCapture logs = await adapter.collectLogs(); + + expect(screenshot.mimeType, 'image/png'); + expect(screenshot.bytes, [137, 80, 78, 71]); + expect(widgetTree.data, isA>()); + expect(logs.data, isA>()); + }, + ); + + test( + 'enters cumulative text for character-by-character runner calls', + () 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': {}}; + }, + ); + + await adapter.clearText(const FinderMatch(id: 'field-1')); + await adapter.enterText(const FinderMatch(id: 'field-1'), 'a'); + await adapter.enterText(const FinderMatch(id: 'field-1'), 'b'); + + expect(calls.map((McpFlutterCommandCall call) => call.name), [ + 'enter_text', + 'enter_text', + 'enter_text', + ]); + expect(calls[0].arguments['text'], ''); + expect(calls[1].arguments['text'], 'a'); + expect(calls[2].arguments['text'], 'ab'); + }, + ); +} diff --git a/test/runtime_adapter_selection_test.dart b/test/runtime_adapter_selection_test.dart index a30e5ce..636ac86 100644 --- a/test/runtime_adapter_selection_test.dart +++ b/test/runtime_adapter_selection_test.dart @@ -3,16 +3,35 @@ import 'package:test/test.dart'; /// Verifies hidden Runtime Adapter selection without launching a Flutter app. void main() { - 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'), - ), - environment: const {}, - ); + test( + 'uses McpFlutterRuntimeAdapter when hidden runtime switch is omitted', + () { + final RuntimeAdapter adapter = RuntimeAdapterSelector.select( + target: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), + ), + environment: const {}, + ); - expect(adapter, isA()); - }); + expect(adapter, isA()); + }, + ); + + test( + 'uses McpFlutterRuntimeAdapter when hidden runtime switch selects it', + () { + final RuntimeAdapter adapter = RuntimeAdapterSelector.select( + target: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), + ), + environment: const { + RuntimeAdapterSelector.environmentKey: 'mcp_flutter', + }, + ); + + expect(adapter, isA()); + }, + ); test('uses PilotRuntimeAdapter when hidden runtime switch selects it', () { final RuntimeAdapter adapter = RuntimeAdapterSelector.select( @@ -46,24 +65,4 @@ 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'), - ), - ), - ); - }); } From 1a295964182c532a0fc0582693e2cc9b1a3ea02b Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 22:46:13 +0800 Subject: [PATCH 05/15] no-mistakes(review): Restore default runtime setup and smoke compatibility --- lib/src/app_setup.dart | 73 ++++++------ lib/src/commands/app_setup_commands.dart | 50 ++++----- .../runtime/mcp_flutter_runtime_adapter.dart | 56 ++++++++-- test/app_setup_test.dart | 42 ++++--- test/cli_test.dart | 35 +++--- test/mcp_flutter_runtime_adapter_test.dart | 40 +++++++ tool/run_mcp_flutter_smoke.dart | 105 ++++++++++++++++++ 7 files changed, 286 insertions(+), 115 deletions(-) create mode 100644 tool/run_mcp_flutter_smoke.dart diff --git a/lib/src/app_setup.dart b/lib/src/app_setup.dart index 962f50f..2fe1252 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 -/// `pilot_runtime` is available as a runtime dependency, and whether the app -/// entrypoint appears to initialize `PilotRuntimeBinding`. +/// `mcp_toolkit` is available as a runtime dependency, and whether the app +/// entrypoint appears to call `bootstrapFlutter`. class AppSetupStatus { const AppSetupStatus({ required this.isFlutterPackage, - required this.hasPilotRuntimeDependency, - required this.hasPilotRuntimeBinding, + required this.hasMcpToolkitDependency, + required this.hasBootstrapFlutter, }); /// Whether `pubspec.yaml` declares `dependencies.flutter.sdk: flutter`. final bool isFlutterPackage; - /// Whether `pilot_runtime` is declared under runtime `dependencies`. - final bool hasPilotRuntimeDependency; + /// Whether `mcp_toolkit` is declared under runtime `dependencies`. + final bool hasMcpToolkitDependency; - /// Whether `lib/main.dart` contains the pilot runtime binding call. - final bool hasPilotRuntimeBinding; + /// Whether `lib/main.dart` contains the MCP Toolkit bootstrap call. + final bool hasBootstrapFlutter; /// Whether all required Flutter Pilot app setup was found. bool get isComplete => - isFlutterPackage && hasPilotRuntimeDependency && hasPilotRuntimeBinding; + isFlutterPackage && hasMcpToolkitDependency && hasBootstrapFlutter; } -/// Result of trying to add `pilot_runtime` to a Target App Package. +/// Result of trying to add `mcp_toolkit` 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.addedPilotRuntimeDependency, + required this.addedMcpToolkitDependency, }); /// Setup status observed before any dependency installation. final AppSetupStatus status; - /// Whether initialization successfully ran `flutter pub add pilot_runtime`. - final bool addedPilotRuntimeDependency; + /// Whether initialization successfully ran `flutter pub add mcp_toolkit`. + final bool addedMcpToolkitDependency; } /// Adds the safe Flutter Pilot dependency setup when it is missing. @@ -69,40 +69,38 @@ typedef AppSetupDependencyInstaller = class AppSetupInitializer { AppSetupInitializer._(); - /// Add `pilot_runtime` when the current package lacks the runtime dependency. + /// Add `mcp_toolkit` when the current package lacks the runtime dependency. /// /// Args: /// `packageDirectory` is the Target App Package root. - /// `addPilotRuntimeDependency` runs the dependency tool. Production code uses - /// `flutter pub add pilot_runtime`; tests can pass a fake installer. + /// `addMcpToolkitDependency` runs the dependency tool. Production code uses + /// `flutter pub add mcp_toolkit`; 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 addPilotRuntimeDependency, + required AppSetupDependencyInstaller addMcpToolkitDependency, }) async { final AppSetupStatus status = AppSetupChecker.check(packageDirectory); - if (!status.hasPilotRuntimeDependency) { - final AppSetupInstallResult installResult = - await addPilotRuntimeDependency(packageDirectory); + if (!status.hasMcpToolkitDependency) { + final AppSetupInstallResult installResult = await addMcpToolkitDependency( + packageDirectory, + ); if (!installResult.succeeded) { throw AppSetupInstallException(installResult); } return AppSetupInitResult( status: status, - addedPilotRuntimeDependency: true, + addedMcpToolkitDependency: true, ); } - return AppSetupInitResult( - status: status, - addedPilotRuntimeDependency: false, - ); + return AppSetupInitResult(status: status, addedMcpToolkitDependency: false); } } -/// Failure returned when `flutter pub add pilot_runtime` does not succeed. +/// Failure returned when `flutter pub add mcp_toolkit` does not succeed. class AppSetupInstallException implements Exception { const AppSetupInstallException(this.result); @@ -114,7 +112,8 @@ class AppSetupInstallException implements Exception { class AppSetupChecker { AppSetupChecker._(); - static const String bindingCall = 'PilotRuntimeBinding.ensureInitialized'; + static const String bootstrapCall = + 'MCPToolkitBinding.instance.bootstrapFlutter'; /// Inspect `packageDirectory` without modifying files. /// @@ -130,22 +129,22 @@ class AppSetupChecker { if (!pubspecFile.existsSync()) { return const AppSetupStatus( isFlutterPackage: false, - hasPilotRuntimeDependency: false, - hasPilotRuntimeBinding: false, + hasMcpToolkitDependency: false, + hasBootstrapFlutter: false, ); } final Object? pubspec = loadYaml(pubspecFile.readAsStringSync()); final bool isFlutterPackage = _isDeclaresFlutterSdk(pubspec); - final bool hasPilotRuntimeDependency = _hasRuntimePilotRuntime(pubspec); + final bool hasMcpToolkitDependency = _hasRuntimeMcpToolkit(pubspec); final File mainFile = File('${packageDirectory.path}/lib/main.dart'); - final bool hasPilotRuntimeBinding = + final bool hasBootstrapFlutter = mainFile.existsSync() && - mainFile.readAsStringSync().contains(bindingCall); + mainFile.readAsStringSync().contains(bootstrapCall); return AppSetupStatus( isFlutterPackage: isFlutterPackage, - hasPilotRuntimeDependency: hasPilotRuntimeDependency, - hasPilotRuntimeBinding: hasPilotRuntimeBinding, + hasMcpToolkitDependency: hasMcpToolkitDependency, + hasBootstrapFlutter: hasBootstrapFlutter, ); } @@ -157,9 +156,9 @@ class AppSetupChecker { return false; } - /// Return whether the pubspec declares `pilot_runtime` as a runtime dependency. - static bool _hasRuntimePilotRuntime(Object? pubspec) { - if (pubspec case {'dependencies': {'pilot_runtime': Object()}}) { + /// Return whether the pubspec declares `mcp_toolkit` as a runtime dependency. + static bool _hasRuntimeMcpToolkit(Object? pubspec) { + if (pubspec case {'dependencies': {'mcp_toolkit': Object()}}) { return true; } return false; diff --git a/lib/src/commands/app_setup_commands.dart b/lib/src/commands/app_setup_commands.dart index f9e24ff..46e1e8e 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.hasPilotRuntimeDependency) { + if (!status.hasMcpToolkitDependency) { stdout.writeln( - '❌ pilot_runtime dependency missing: run `flutter pub add pilot_runtime`', + '❌ MCP Toolkit dependency missing: run `flutter pub add mcp_toolkit`', ); } - if (!status.hasPilotRuntimeBinding) { + if (!status.hasBootstrapFlutter) { _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 `pilot_runtime` dependency through Flutter tooling, then -/// reports whether the app entrypoint still needs manual binding code. +/// It can add the `mcp_toolkit` dependency through Flutter tooling, then +/// reports whether the app entrypoint still needs manual bootstrap 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, - addPilotRuntimeDependency: _addPilotRuntimeDependency, + addMcpToolkitDependency: _addMcpToolkitDependency, ); stdout.writeln('Flutter Pilot init'); stdout.writeln(''); - if (result.addedPilotRuntimeDependency) { - stdout.writeln('✅ Added pilot_runtime dependency.'); + if (result.addedMcpToolkitDependency) { + stdout.writeln('✅ Added MCP Toolkit dependency.'); } else { - stdout.writeln('✅ pilot_runtime dependency already exists.'); + stdout.writeln('✅ MCP Toolkit dependency already exists.'); } - if (result.status.hasPilotRuntimeBinding) { - stdout.writeln('✅ PilotRuntimeBinding already exists.'); + if (result.status.hasBootstrapFlutter) { + stdout.writeln('✅ bootstrapFlutter already exists.'); } else { _writeBootstrapGuidance(); } return 0; } on AppSetupInstallException catch (error) { - stderr.writeln('Failed to add pilot_runtime dependency.'); + stderr.writeln('Failed to add MCP Toolkit 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 pilot_runtime'); + stderr.writeln('flutter pub add mcp_toolkit'); 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 _addPilotRuntimeDependency( +Future _addMcpToolkitDependency( Directory packageDirectory, ) async { try { final ProcessResult result = await Process.run('flutter', [ 'pub', 'add', - 'pilot_runtime', + 'mcp_toolkit', ], workingDirectory: packageDirectory.path); if (result.exitCode == 0) { return const AppSetupInstallResult.success(); @@ -136,21 +136,21 @@ Future _addPilotRuntimeDependency( } } -/// Print the manual app entrypoint change required for pilot_runtime. +/// Print the manual app entrypoint change required for MCP Toolkit. void _writeBootstrapGuidance() { stdout.writeln( - '❌ PilotRuntimeBinding missing: add ' - 'PilotRuntimeBinding.ensureInitialized() in lib/main.dart', + '❌ bootstrapFlutter missing: add ' + 'MCPToolkitBinding.instance.bootstrapFlutter in lib/main.dart', ); stdout.writeln(''); - stdout.writeln('Add the pilot_runtime import:'); - stdout.writeln("import 'package:pilot_runtime/pilot_runtime.dart';"); + stdout.writeln('Add the MCP Toolkit import:'); + stdout.writeln("import 'package:mcp_toolkit/mcp_toolkit.dart';"); 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('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('}'); } diff --git a/lib/src/runtime/mcp_flutter_runtime_adapter.dart b/lib/src/runtime/mcp_flutter_runtime_adapter.dart index c97f9e4..dd41715 100644 --- a/lib/src/runtime/mcp_flutter_runtime_adapter.dart +++ b/lib/src/runtime/mcp_flutter_runtime_adapter.dart @@ -306,7 +306,7 @@ class McpFlutterRuntimeAdapter implements RuntimeAdapter { if (data is! Map) { return const >[]; } - final Object? nodes = data['nodes']; + final Object? nodes = data['nodes'] ?? data['widgets'] ?? data['items']; if (nodes is! List) { return const >[]; } @@ -317,10 +317,10 @@ class McpFlutterRuntimeAdapter implements RuntimeAdapter { } static bool _matchesFinder(Map node, Finder finder) { - return _matchesString(node['label'], finder.byText) && - _matchesString(node['type'], finder.byType) && - _matchesString(node['key'], finder.byKey) && - _matchesString(node['widgetType'], finder.byWidget); + return _matchesString(_nodeText(node), finder.byText) && + _matchesString(_nodeType(node), finder.byType) && + _matchesString(_nodeKey(node), finder.byKey) && + _matchesString(_nodeWidgetType(node), finder.byWidget); } static bool _matchesString(Object? actual, String? expected) { @@ -328,18 +328,20 @@ class McpFlutterRuntimeAdapter implements RuntimeAdapter { } static FinderMatch _matchFromNode(Map node) { - final Object? ref = node['ref']; return FinderMatch( - id: ref is String ? ref : '', - debugLabel: node['label'] as String?, - text: node['label'] as String?, - key: node['key'] as String?, - type: node['type'] as String?, - bounds: _boundsFromNode(node['rect']), + id: _stringField(node, const ['ref', 'id']) ?? '', + debugLabel: _nodeText(node) ?? _nodeType(node), + text: _nodeText(node), + key: _nodeKey(node), + type: _nodeType(node), + bounds: _boundsFromNode(node), ); } static WidgetBounds? _boundsFromNode(Object? value) { + if (value is Map) { + value = value['rect'] ?? value['bounds']; + } if (value is! Map) { return null; } @@ -363,6 +365,36 @@ class McpFlutterRuntimeAdapter implements RuntimeAdapter { return null; } + static String? _nodeText(Map node) { + return _stringField(node, const ['label', 'text', 'name']); + } + + static String? _nodeKey(Map node) { + return _stringField(node, const ['key', 'valueKey']); + } + + static String? _nodeWidgetType(Map node) { + return _stringField(node, const [ + 'widgetType', + 'runtimeType', + 'widget', + ]); + } + + static String? _nodeType(Map node) { + return _stringField(node, const ['type', 'widgetType']); + } + + static 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; + } + static String _scrollDirection({ required double deltaX, required double deltaY, diff --git a/test/app_setup_test.dart b/test/app_setup_test.dart index b5346b0..aca6e43 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 pilot_runtime', + 'reports complete setup for a Flutter package with mcp_toolkit', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') @@ -17,31 +17,30 @@ name: target_app dependencies: flutter: sdk: flutter - pilot_runtime: - path: packages/pilot_runtime + mcp_toolkit: ^0.6.0 '''); _writeMain(packageDirectory, ''' import 'package:flutter/material.dart'; -import 'package:pilot_runtime/pilot_runtime.dart'; +import 'package:mcp_toolkit/mcp_toolkit.dart'; -void main() { - WidgetsFlutterBinding.ensureInitialized(); - PilotRuntimeBinding.ensureInitialized(); - runApp(const Placeholder()); +Future main() async { + await MCPToolkitBinding.instance.bootstrapFlutter( + runApp: () => runApp(const Placeholder()), + ); } '''); final AppSetupStatus status = AppSetupChecker.check(packageDirectory); expect(status.isFlutterPackage, isTrue); - expect(status.hasPilotRuntimeDependency, isTrue); - expect(status.hasPilotRuntimeBinding, isTrue); + expect(status.hasMcpToolkitDependency, isTrue); + expect(status.hasBootstrapFlutter, isTrue); expect(status.isComplete, isTrue); }); }, ); - test('does not accept pilot_runtime from dev dependencies', () async { + test('does not accept mcp_toolkit from dev dependencies', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') ..createSync(recursive: true); @@ -51,26 +50,25 @@ dependencies: flutter: sdk: flutter dev_dependencies: - pilot_runtime: - path: packages/pilot_runtime + mcp_toolkit: ^0.6.0 '''); _writeMain(packageDirectory, ''' void main() { - PilotRuntimeBinding.ensureInitialized(); + MCPToolkitBinding.instance.bootstrapFlutter(); } '''); final AppSetupStatus status = AppSetupChecker.check(packageDirectory); expect(status.isFlutterPackage, isTrue); - expect(status.hasPilotRuntimeDependency, isFalse); - expect(status.hasPilotRuntimeBinding, isTrue); + expect(status.hasMcpToolkitDependency, isFalse); + expect(status.hasBootstrapFlutter, isTrue); expect(status.isComplete, isFalse); }); }); test( - 'init adds pilot_runtime dependency when runtime dependency is missing', + 'init adds mcp_toolkit dependency when runtime dependency is missing', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') @@ -85,20 +83,20 @@ dependencies: final AppSetupInitResult result = await AppSetupInitializer.initialize( packageDirectory, - addPilotRuntimeDependency: (Directory directory) async { + addMcpToolkitDependency: (Directory directory) async { installDirectories.add(directory); return const AppSetupInstallResult.success(); }, ); - expect(result.addedPilotRuntimeDependency, isTrue); - expect(result.status.hasPilotRuntimeDependency, isFalse); + expect(result.addedMcpToolkitDependency, isTrue); + expect(result.status.hasMcpToolkitDependency, isFalse); expect(installDirectories, [packageDirectory]); }); }, ); - test('init stops when pilot_runtime dependency install fails', () async { + test('init stops when mcp_toolkit dependency install fails', () async { await FileTestkit.runZoned(() async { final Directory packageDirectory = Directory('/target_app') ..createSync(recursive: true); @@ -112,7 +110,7 @@ dependencies: expect( () => AppSetupInitializer.initialize( packageDirectory, - addPilotRuntimeDependency: (Directory directory) async { + addMcpToolkitDependency: (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 f33d939..993bfdc 100644 --- a/test/cli_test.dart +++ b/test/cli_test.dart @@ -345,19 +345,18 @@ environment: dependencies: flutter: sdk: flutter - pilot_runtime: - path: packages/pilot_runtime + mcp_toolkit: ^0.6.0 '''); final Directory libDirectory = Directory('${tempDirectory.path}/lib') ..createSync(recursive: true); File('${libDirectory.path}/main.dart').writeAsStringSync(''' import 'package:flutter/material.dart'; -import 'package:pilot_runtime/pilot_runtime.dart'; +import 'package:mcp_toolkit/mcp_toolkit.dart'; -void main() { - WidgetsFlutterBinding.ensureInitialized(); - PilotRuntimeBinding.ensureInitialized(); - runApp(const Placeholder()); +Future main() async { + await MCPToolkitBinding.instance.bootstrapFlutter( + runApp: () => runApp(const Placeholder()), + ); } '''); @@ -411,13 +410,13 @@ void main() { expect( result.stdout, contains( - '❌ pilot_runtime dependency missing: run `flutter pub add pilot_runtime`', + '❌ MCP Toolkit dependency missing: run `flutter pub add mcp_toolkit`', ), ); expect( result.stdout, contains( - '❌ PilotRuntimeBinding missing: add PilotRuntimeBinding.ensureInitialized() in lib/main.dart', + '❌ bootstrapFlutter missing: add MCPToolkitBinding.instance.bootstrapFlutter in lib/main.dart', ), ); expect(result.stderr, isEmpty); @@ -500,14 +499,13 @@ environment: dependencies: flutter: sdk: flutter - pilot_runtime: - path: packages/pilot_runtime + mcp_toolkit: ^0.6.0 '''); final Directory libDirectory = Directory('${tempDirectory.path}/lib') ..createSync(recursive: true); File('${libDirectory.path}/main.dart').writeAsStringSync(''' void main() { - PilotRuntimeBinding.ensureInitialized(); + MCPToolkitBinding.instance.bootstrapFlutter(); } '''); @@ -521,9 +519,9 @@ void main() { expect(result.stdout, contains('Flutter Pilot init')); expect( result.stdout, - contains('✅ pilot_runtime dependency already exists.'), + contains('✅ MCP Toolkit dependency already exists.'), ); - expect(result.stdout, contains('✅ PilotRuntimeBinding already exists.')); + expect(result.stdout, contains('✅ bootstrapFlutter already exists.')); expect(result.stderr, isEmpty); } finally { tempDirectory.deleteSync(recursive: true); @@ -545,8 +543,7 @@ environment: dependencies: flutter: sdk: flutter - pilot_runtime: - path: packages/pilot_runtime + mcp_toolkit: ^0.6.0 '''); final Directory libDirectory = Directory('${tempDirectory.path}/lib') ..createSync(recursive: true); @@ -566,16 +563,16 @@ void main() { expect( result.stdout, contains( - '❌ PilotRuntimeBinding missing: add PilotRuntimeBinding.ensureInitialized() in lib/main.dart', + '❌ bootstrapFlutter missing: add MCPToolkitBinding.instance.bootstrapFlutter in lib/main.dart', ), ); expect( result.stdout, - contains("import 'package:pilot_runtime/pilot_runtime.dart';"), + contains("import 'package:mcp_toolkit/mcp_toolkit.dart';"), ); expect( result.stdout, - contains('PilotRuntimeBinding.ensureInitialized();'), + contains('MCPToolkitBinding.instance.bootstrapFlutter('), ); } finally { tempDirectory.deleteSync(recursive: true); diff --git a/test/mcp_flutter_runtime_adapter_test.dart b/test/mcp_flutter_runtime_adapter_test.dart index 7e90d25..5057e7b 100644 --- a/test/mcp_flutter_runtime_adapter_test.dart +++ b/test/mcp_flutter_runtime_adapter_test.dart @@ -82,4 +82,44 @@ void main() { expect(calls[2].arguments['text'], 'ab'); }, ); + + test('matches alternate semantic snapshot response shapes', () async { + final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( + target: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), + ), + commandRunner: (McpFlutterCommandCall call) async { + return { + 'ok': true, + 'data': { + 'widgets': [ + { + 'id': 'submit-1', + 'text': 'Submit', + 'valueKey': 'submit_button', + 'widgetType': 'button', + 'bounds': { + 'left': 10, + 'top': 20, + 'width': 80, + 'height': 40, + }, + }, + ], + }, + }; + }, + ); + + final List matches = await adapter.resolveFinder( + const Finder(byText: 'Submit', byType: 'button', byKey: 'submit_button'), + ); + + expect(matches, hasLength(1)); + expect(matches.single.id, 'submit-1'); + expect(matches.single.text, 'Submit'); + expect(matches.single.key, 'submit_button'); + expect(matches.single.type, 'button'); + expect(matches.single.bounds?.left, 10); + }); } diff --git a/tool/run_mcp_flutter_smoke.dart b/tool/run_mcp_flutter_smoke.dart new file mode 100644 index 0000000..909a53d --- /dev/null +++ b/tool/run_mcp_flutter_smoke.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import 'package:flutter_pilot/flutter_pilot.dart'; +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`, runs the smoke +/// Scenario through the default `mcp_flutter` Runtime Adapter, 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 Scenario scenario = ScenarioParser.parseFile( + 'examples/smoke_app/smoke_scenario.yaml', + ); + final ScenarioRunner runner = ScenarioRunner( + adapter: McpFlutterRuntimeAdapter( + target: RuntimeTarget(vmServiceUri: Uri.parse(target)), + ), + outputDirectory: Directory.current, + ); + final ScenarioRunReport report; + try { + report = await runner.run(scenario); + } on RuntimeOperationException catch (error) { + stderr.writeln(error.message); + exitCode = SmokeVerifierExitCodes.scenarioRunFailed; + return; + } + + final String reportPath = '${report.runDirectoryPath}/run_report.json'; + final SmokeVerificationResult verification = + SmokeRunVerifier.verifyReportFile(File(reportPath)); + if (!verification.passed) { + stderr.writeln('Smoke verification failed:'); + for (final String error in verification.errors) { + stderr.writeln('- $error'); + } + exitCode = report.status == ScenarioRunStatus.passed + ? SmokeVerifierExitCodes.reportVerificationFailed + : 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; +} From 73d9d0b8255b34cfaf2364e8ca6aabc3ad78a128 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 23:02:15 +0800 Subject: [PATCH 06/15] no-mistakes(review): Restore MCP smoke and screenshot compatibility --- README.md | 27 +++---- README_zh.md | 27 ++++--- examples/smoke_app/lib/main.dart | 14 ++-- examples/smoke_app/pubspec.lock | 72 +++++++++++++++++++ examples/smoke_app/pubspec.yaml | 1 + .../runtime/mcp_flutter_runtime_adapter.dart | 44 +++++++++--- test/mcp_flutter_runtime_adapter_test.dart | 36 ++++++++++ 7 files changed, 178 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 6a96d44..6ad03d2 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ 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 drives debug Runtime Targets through `pilot_runtime`, the -Flutter Pilot-owned runtime package for app-side interaction and inspection. +Flutter Pilot currently drives debug Runtime Targets through `mcp_flutter`. +The built-in `pilot_runtime` package is under active development as the next +runtime bridge. ## What It Does @@ -100,9 +101,9 @@ Install the Flutter Pilot CLI: dart pub global activate flutter_pilot ``` -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. +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. From the Target App Package, initialize the safe app-side setup: @@ -110,18 +111,18 @@ From the Target App Package, initialize the safe app-side setup: flutter_pilot init ``` -`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: +`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: ```dart import 'package:flutter/material.dart'; -import 'package:pilot_runtime/pilot_runtime.dart'; +import 'package:mcp_toolkit/mcp_toolkit.dart'; -void main() { - WidgetsFlutterBinding.ensureInitialized(); - PilotRuntimeBinding.ensureInitialized(); - runApp(const MyApp()); +Future main() async { + await MCPToolkitBinding.instance.bootstrapFlutter( + runApp: () => runApp(const MyApp()), + ); } ``` diff --git a/README_zh.md b/README_zh.md index 785b2d8..61a2515 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,11 +9,11 @@ Flutter UI 操作路径描述成可提交、可分享、可重复执行的 YAML 调试所需的上下文整理成适合开发者、CI 和 AI 编码代理阅读的产物。 > 项目状态:Flutter Pilot 仍在开发中。当前实现重点是 Dart CLI、Scenario -> YAML 解析与校验、报告生成和命令外壳;通过 `pilot_runtime` 驱动 Flutter -> UI 的执行能力正在建设中。 +> YAML 解析与校验、报告生成和命令外壳;通过 `mcp_flutter` 驱动 Flutter +> UI。内置的 `pilot_runtime` 运行时桥接仍在建设中。 -Flutter Pilot 通过 `pilot_runtime` 驱动 Flutter debug Runtime Target。 -`pilot_runtime` 是 Flutter Pilot 自有的运行时包,负责应用侧交互和检查能力。 +Flutter Pilot 当前通过 `mcp_flutter` 驱动 Flutter debug Runtime Target。 +内置的 `pilot_runtime` 包正在作为下一代运行时桥接开发。 ## 它能做什么 @@ -94,8 +94,8 @@ HTML timeline report 会把同一次 UI 旅程转换成可视化审查界面。 dart pub global activate flutter_pilot ``` -Flutter Pilot 通过 `pilot_runtime` 驱动 Flutter 应用。目标应用需要先初始化 -Pilot Runtime binding,`flutter_pilot test` 才能和它交互。 +Flutter Pilot 通过 `mcp_flutter` 驱动 Flutter 应用。目标应用需要先暴露 MCP +Toolkit 运行时扩展,`flutter_pilot test` 才能和它交互。 在 Target App Package 中初始化安全的应用侧配置: @@ -103,18 +103,17 @@ Pilot Runtime binding,`flutter_pilot test` 才能和它交互。 flutter_pilot init ``` -`init` 会在缺少 `pilot_runtime` 依赖时安装它。它不会修改 `lib/main.dart`; -如果缺少 `PilotRuntimeBinding.ensureInitialized()`,它会打印需要手动添加的 -import 和 binding 调用: +`init` 会在缺少 MCP Toolkit 运行时依赖时安装它。它不会修改 `lib/main.dart`; +如果缺少 `bootstrapFlutter`,它会打印需要手动添加的 import 和 `runApp` 包裹代码: ```dart import 'package:flutter/material.dart'; -import 'package:pilot_runtime/pilot_runtime.dart'; +import 'package:mcp_toolkit/mcp_toolkit.dart'; -void main() { - WidgetsFlutterBinding.ensureInitialized(); - PilotRuntimeBinding.ensureInitialized(); - runApp(const MyApp()); +Future main() async { + await MCPToolkitBinding.instance.bootstrapFlutter( + runApp: () => runApp(const MyApp()), + ); } ``` diff --git a/examples/smoke_app/lib/main.dart b/examples/smoke_app/lib/main.dart index 5fcbba5..4ff7898 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:pilot_runtime/pilot_runtime.dart'; +import 'package:mcp_toolkit/mcp_toolkit.dart'; -/// Start the Flutter Pilot smoke app with pilot_runtime extensions enabled. -void main() { - WidgetsFlutterBinding.ensureInitialized(); - PilotRuntimeBinding.ensureInitialized(); - runApp(const SmokeApp()); +/// Start the Flutter Pilot smoke app with MCP Toolkit extensions enabled. +Future main() async { + await MCPToolkitBinding.instance.bootstrapFlutter( + runApp: () => runApp(const SmokeApp()), + ); } -/// Minimal Flutter app used by the real `pilot_runtime` smoke Scenario. +/// Minimal Flutter app used by the real `mcp_flutter` smoke Scenario. class SmokeApp extends StatelessWidget { const SmokeApp({super.key}); diff --git a/examples/smoke_app/pubspec.lock b/examples/smoke_app/pubspec.lock index 71c0f48..97698d9 100644 --- a/examples/smoke_app/pubspec.lock +++ b/examples/smoke_app/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_mcp: + dependency: transitive + description: + name: dart_mcp + sha256: "852b51da915d679be8d051e2e3b6c68ed19fe685f1c676bb35272a2d73ceac7a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -75,6 +91,30 @@ 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: @@ -123,6 +163,14 @@ 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: @@ -175,6 +223,14 @@ 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: @@ -199,6 +255,22 @@ 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 4b7d8b2..f660a20 100644 --- a/examples/smoke_app/pubspec.yaml +++ b/examples/smoke_app/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: cupertino_icons: ^1.0.8 pilot_runtime: path: ../../packages/pilot_runtime + mcp_toolkit: ^3.0.0 dev_dependencies: flutter_test: diff --git a/lib/src/runtime/mcp_flutter_runtime_adapter.dart b/lib/src/runtime/mcp_flutter_runtime_adapter.dart index dd41715..1d1fad3 100644 --- a/lib/src/runtime/mcp_flutter_runtime_adapter.dart +++ b/lib/src/runtime/mcp_flutter_runtime_adapter.dart @@ -413,19 +413,45 @@ class McpFlutterRuntimeAdapter implements RuntimeAdapter { } static String? _firstScreenshotImage(Object? data) { - if (data is Map) { - final Object? images = data['images']; - if (images is List && images.isNotEmpty) { - final Object? image = images.first; - return image is String ? image : null; + if (data is! Map) { + return null; + } + final String? directImage = _stringField(data, const [ + 'image', + 'base64', + 'png', + 'screenshot', + ]); + 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? image = data['image']; + } + final String? imageFromImages = _firstImageFromList(data['images']); + if (imageFromImages != null) { + return imageFromImages; + } + return _firstImageFromList(data['screenshots']); + } + + static String? _firstImageFromList(Object? images) { + if (images is! List) { + return null; + } + for (final Object? image in images) { if (image is String) { return image; } - final Object? screenshot = data['screenshot']; - if (screenshot is String) { - return screenshot; + if (image is Map) { + final String? nestedImage = _firstScreenshotImage(image); + if (nestedImage != null) { + return nestedImage; + } } } return null; diff --git a/test/mcp_flutter_runtime_adapter_test.dart b/test/mcp_flutter_runtime_adapter_test.dart index 5057e7b..ab0408a 100644 --- a/test/mcp_flutter_runtime_adapter_test.dart +++ b/test/mcp_flutter_runtime_adapter_test.dart @@ -54,6 +54,42 @@ void main() { }, ); + test('captures screenshots from tolerant toolkit response shapes', () async { + final List screenshotData = [ + {'base64': 'iVBORw=='}, + {'png': 'iVBORw=='}, + { + 'screenshot': {'image': 'iVBORw=='}, + }, + { + 'images': [ + {'ignored': true}, + {'base64': 'iVBORw=='}, + ], + }, + { + 'screenshots': [ + {'png': 'iVBORw=='}, + ], + }, + ]; + + for (final Object? data in screenshotData) { + final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( + target: RuntimeTarget( + vmServiceUri: Uri.parse('ws://127.0.0.1:1234/example=/ws'), + ), + commandRunner: (McpFlutterCommandCall call) async { + return {'ok': true, 'data': data}; + }, + ); + + final ScreenshotCapture screenshot = await adapter.captureScreenshot(); + + expect(screenshot.bytes, [137, 80, 78, 71]); + } + }); + test( 'enters cumulative text for character-by-character runner calls', () async { From fc8f3b3dba743cc245eeeb9e7b82187be495753a Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 23:17:03 +0800 Subject: [PATCH 07/15] no-mistakes(review): Fix finder and scroll target selection --- .../lib/src/finder_resolver.dart | 100 ++++++++++++------ .../lib/src/scroll_performer.dart | 21 +++- .../test/pilot_runtime_finder_test.dart | 78 ++++++++++++++ 3 files changed, 168 insertions(+), 31 deletions(-) diff --git a/packages/pilot_runtime/lib/src/finder_resolver.dart b/packages/pilot_runtime/lib/src/finder_resolver.dart index 3cb9b2f..34aa1b1 100644 --- a/packages/pilot_runtime/lib/src/finder_resolver.dart +++ b/packages/pilot_runtime/lib/src/finder_resolver.dart @@ -47,8 +47,8 @@ class PilotRuntimeFinderResolver { matches.add( PilotRuntimeFinderMatch( handle: handleForElement(element), - text: evidence.textForDiagnostics, - semanticType: evidence.semanticTypeForDiagnostics, + text: evidence.diagnosticTextFor(byText), + semanticType: evidence.diagnosticSemanticTypeFor(byType), key: evidence.valueKey, matchedWidgetType: evidence.widgetType, actionWidgetType: element.widget.runtimeType.toString(), @@ -126,16 +126,16 @@ 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) { + final bool matchesText = hasStructuralConstraint + ? evidence.hasText(byText) + : evidence.ownText == byText; + final bool matchesSemanticType = hasWrapperConstraint + ? evidence.hasSemanticType(byType) + : evidence.semanticType == byType; + if (byText != null && !matchesText) { return false; } - if (byType != null && comparableSemanticType != byType) { + if (byType != null && !matchesSemanticType) { return false; } if (byKey != null && evidence.valueKey != byKey) { @@ -153,12 +153,11 @@ class PilotRuntimeFinderResolver { static _FinderEvidence _evidenceFor(Element element) { final String? ownText = _ownTextFor(element); final String? semanticType = _semanticTypeFor(element); - final String? descendantText = _descendantTextFor(element); return _FinderEvidence( ownText: ownText, - descendantText: descendantText, + descendantTexts: _descendantTextsFor(element), semanticType: semanticType, - descendantSemanticType: _descendantSemanticTypeFor(element), + descendantSemanticTypes: _descendantSemanticTypesFor(element), valueKey: _valueKeyFor(element), widgetType: element.widget.runtimeType.toString(), ); @@ -189,22 +188,28 @@ class PilotRuntimeFinderResolver { return null; } - static String? _descendantTextFor(Element element) { - String? matchedText; + static List _descendantTextsFor(Element element) { + final List texts = []; element.visitChildren((Element child) { - matchedText ??= _ownTextFor(child); - matchedText ??= _descendantTextFor(child); + final String? text = _ownTextFor(child); + if (text != null) { + texts.add(text); + } + texts.addAll(_descendantTextsFor(child)); }); - return matchedText; + return texts; } - static String? _descendantSemanticTypeFor(Element element) { - String? matchedType; + static List _descendantSemanticTypesFor(Element element) { + final List semanticTypes = []; element.visitChildren((Element child) { - matchedType ??= _semanticTypeFor(child); - matchedType ??= _descendantSemanticTypeFor(child); + final String? semanticType = _semanticTypeFor(child); + if (semanticType != null) { + semanticTypes.add(semanticType); + } + semanticTypes.addAll(_descendantSemanticTypesFor(child)); }); - return matchedType; + return semanticTypes; } static String? _semanticTypeFor(Element element) { @@ -313,22 +318,57 @@ class PilotRuntimeFinderResolver { class _FinderEvidence { const _FinderEvidence({ this.ownText, - this.descendantText, + required this.descendantTexts, this.semanticType, - this.descendantSemanticType, + required this.descendantSemanticTypes, this.valueKey, this.widgetType, }); final String? ownText; - final String? descendantText; + final List descendantTexts; final String? semanticType; - final String? descendantSemanticType; + final List descendantSemanticTypes; final String? valueKey; final String? widgetType; - String? get textForDiagnostics => ownText ?? descendantText; + bool hasText(String? text) { + if (text == null) { + return true; + } + return ownText == text || descendantTexts.contains(text); + } + + bool hasSemanticType(String? type) { + if (type == null) { + return true; + } + return semanticType == type || descendantSemanticTypes.contains(type); + } + + String? diagnosticTextFor(String? requestedText) { + if (requestedText != null && hasText(requestedText)) { + return requestedText; + } + if (ownText != null) { + return ownText; + } + if (descendantTexts.isNotEmpty) { + return descendantTexts.first; + } + return null; + } - String? get semanticTypeForDiagnostics => - semanticType ?? descendantSemanticType; + String? diagnosticSemanticTypeFor(String? requestedType) { + if (requestedType != null && hasSemanticType(requestedType)) { + return requestedType; + } + if (semanticType != null) { + return semanticType; + } + if (descendantSemanticTypes.isNotEmpty) { + return descendantSemanticTypes.first; + } + return null; + } } diff --git a/packages/pilot_runtime/lib/src/scroll_performer.dart b/packages/pilot_runtime/lib/src/scroll_performer.dart index 6bf5640..964a40e 100644 --- a/packages/pilot_runtime/lib/src/scroll_performer.dart +++ b/packages/pilot_runtime/lib/src/scroll_performer.dart @@ -139,7 +139,7 @@ class PilotRuntimeScrollPerformer { static Element? _primaryScrollable() { final List scrollables = []; PilotRuntimeFinderResolver.visitVisibleElements((Element element) { - if (element.widget is Scrollable) { + if (_isPrimaryScrollableCandidate(element)) { scrollables.add(element); } }); @@ -149,6 +149,13 @@ class PilotRuntimeScrollPerformer { return scrollables.single; } + static bool _isPrimaryScrollableCandidate(Element element) { + if (element.widget is! Scrollable) { + return false; + } + return !_hasAncestorWidget(element); + } + static Offset? _centerFor(Element element) { final RenderObject? renderObject = element.renderObject; if (renderObject is! RenderBox || !renderObject.hasSize) { @@ -166,4 +173,16 @@ class PilotRuntimeScrollPerformer { }) { return {'ok': false, 'code': code, 'message': message}; } + + static bool _hasAncestorWidget(Element element) { + bool found = false; + element.visitAncestorElements((Element ancestor) { + if (ancestor.widget is T) { + found = true; + return false; + } + return true; + }); + return found; + } } diff --git a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index fcb369e..7e98ff5 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -354,6 +354,43 @@ void main() { expect(match['matchedWidgetType'], 'Padding'); }); + testWidgets('combines wrapper key with later descendant text and type', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + key: const ValueKey('message_wrapper'), + padding: const EdgeInsets.all(8), + child: Column( + children: [ + const Text('Leading'), + Semantics(header: true, child: const Text('Saved')), + ], + ), + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byText: 'Saved', + byType: 'header', + byKey: 'message_wrapper', + ); + + expect(response['matches'], hasLength(1)); + final Map match = _singleMatch(response); + expect(match['text'], 'Saved'); + expect(match['semanticType'], 'header'); + expect(match['key'], 'message_wrapper'); + expect(match['matchedWidgetType'], 'Padding'); + }); + testWidgets('tap performs semantic tap action for a resolved handle', ( WidgetTester tester, ) async { @@ -705,6 +742,47 @@ void main() { expect(controller.offset, greaterThan(0)); }); + testWidgets('untargeted scroll ignores editable text internals', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final ScrollController controller = ScrollController(); + final TextEditingController textController = TextEditingController(); + addTearDown(controller.dispose); + addTearDown(textController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + TextField(controller: textController), + Expanded( + 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 { From a0dc812f4ea230c4fde6df6f0e959b032127b2ba Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 23:27:10 +0800 Subject: [PATCH 08/15] no-mistakes(review): Fix runtime text and finder guards --- examples/smoke_app/README.md | 12 ++- .../lib/src/finder_resolver.dart | 6 ++ .../pilot_runtime/lib/src/text_performer.dart | 16 +++- .../test/pilot_runtime_finder_test.dart | 79 +++++++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/examples/smoke_app/README.md b/examples/smoke_app/README.md index cb8498e..320a7cf 100644 --- a/examples/smoke_app/README.md +++ b/examples/smoke_app/README.md @@ -40,7 +40,8 @@ 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_tap.yaml \ +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 ``` @@ -55,7 +56,8 @@ Expected result: To verify the failure path, run: ```bash -dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_non_tappable.yaml \ +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 ``` @@ -81,7 +83,8 @@ 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 \ +FLUTTER_PILOT_RUNTIME=pilot_runtime \ + dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_scroll.yaml \ --target lib/pilot_runtime_scroll_demo.dart \ --device macos ``` @@ -95,7 +98,8 @@ Expected result: To verify the failure path, run: ```bash -dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_scroll_non_scrollable.yaml \ +FLUTTER_PILOT_RUNTIME=pilot_runtime \ + dart run ../../bin/flutter_pilot.dart test pilot/pilot_runtime_scroll_non_scrollable.yaml \ --target lib/pilot_runtime_scroll_demo.dart \ --device macos ``` diff --git a/packages/pilot_runtime/lib/src/finder_resolver.dart b/packages/pilot_runtime/lib/src/finder_resolver.dart index 34aa1b1..09acd60 100644 --- a/packages/pilot_runtime/lib/src/finder_resolver.dart +++ b/packages/pilot_runtime/lib/src/finder_resolver.dart @@ -191,6 +191,9 @@ class PilotRuntimeFinderResolver { static List _descendantTextsFor(Element element) { final List texts = []; element.visitChildren((Element child) { + if (!_isVisible(child)) { + return; + } final String? text = _ownTextFor(child); if (text != null) { texts.add(text); @@ -203,6 +206,9 @@ class PilotRuntimeFinderResolver { static List _descendantSemanticTypesFor(Element element) { final List semanticTypes = []; element.visitChildren((Element child) { + if (!_isVisible(child)) { + return; + } final String? semanticType = _semanticTypeFor(child); if (semanticType != null) { semanticTypes.add(semanticType); diff --git a/packages/pilot_runtime/lib/src/text_performer.dart b/packages/pilot_runtime/lib/src/text_performer.dart index 276e3f6..adeb96e 100644 --- a/packages/pilot_runtime/lib/src/text_performer.dart +++ b/packages/pilot_runtime/lib/src/text_performer.dart @@ -61,12 +61,20 @@ class PilotRuntimeTextPerformer { element.visitChildren((Element child) { state ??= _editableStateFromElement(child); }); - return state; + final EditableTextState? editableState = state; + if (editableState == null || !_isEditable(editableState)) { + return null; + } + return editableState; } static EditableTextState? _editableStateFromElement(Element element) { if (element is StatefulElement && element.state is EditableTextState) { - return element.state as EditableTextState; + final EditableTextState state = element.state as EditableTextState; + if (_isEditable(state)) { + return state; + } + return null; } EditableTextState? state; element.visitChildren((Element child) { @@ -75,6 +83,10 @@ class PilotRuntimeTextPerformer { return state; } + static bool _isEditable(EditableTextState state) { + return !state.widget.readOnly; + } + static Map _failure(String handle) { return { 'ok': false, diff --git a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index 7e98ff5..ecb7960 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -592,6 +592,85 @@ void main() { expect(typeResponse['message'], contains('editable text')); }); + testWidgets('type fails clearly for a read-only text field', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final TextEditingController controller = TextEditingController( + text: 'locked', + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + key: const ValueKey('read_only_input'), + controller: controller, + readOnly: true, + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byKey: 'read_only_input', + byType: 'textField', + ); + final Map match = _singleMatch(response); + + final Map clearResponse = await _clearText( + extensions, + handle: match['handle']! as String, + ); + final Map enterResponse = await _enterText( + extensions, + handle: match['handle']! as String, + text: 'a', + ); + + expect(clearResponse['ok'], false); + expect(clearResponse['code'], 'notEditableText'); + expect(enterResponse['ok'], false); + expect(enterResponse['code'], 'notEditableText'); + expect(controller.text, 'locked'); + }); + + testWidgets('wrapper evidence ignores hidden descendant text and types', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + key: const ValueKey('message_wrapper'), + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Offstage( + child: Semantics(header: true, child: Text('Hidden')), + ), + Text('Visible'), + ], + ), + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byText: 'Hidden', + byType: 'header', + byKey: 'message_wrapper', + ); + + expect(response['matches'], isEmpty); + }); + testWidgets('scroll drags a targeted scrollable by logical-pixel deltas', ( WidgetTester tester, ) async { From f004dd7899663c9f239f60c6d159a5e054a1225f Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 23:38:40 +0800 Subject: [PATCH 09/15] no-mistakes(review): Tighten runtime descendant target selection --- README.md | 8 +- README_zh.md | 8 +- .../lib/src/finder_resolver.dart | 5 + .../lib/src/scroll_performer.dart | 6 +- .../pilot_runtime/lib/src/text_performer.dart | 30 +++-- .../test/pilot_runtime_finder_test.dart | 111 ++++++++++++++++++ 6 files changed, 143 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6ad03d2..0df2f6b 100644 --- a/README.md +++ b/README.md @@ -144,13 +144,13 @@ stdout for scripts. Validate a Scenario without connecting to a Flutter app: ```bash -flutter_pilot validate examples/smoke_scenario.yaml +flutter_pilot validate examples/smoke_app/smoke_scenario.yaml ``` Run a Scenario by launching the current Target App Package: ```bash -flutter_pilot test examples/smoke_scenario.yaml +flutter_pilot test examples/smoke_app/smoke_scenario.yaml ``` Run all Project Scenarios from the default Pilot Directory, `pilot/`: @@ -168,7 +168,7 @@ flutter_pilot test pilot/regression Select the Target Device, Flutter flavor, or app entrypoint when needed: ```bash -flutter_pilot test examples/smoke_scenario.yaml \ +flutter_pilot test examples/smoke_app/smoke_scenario.yaml \ --device \ --flavor staging \ --target lib/main_staging.dart @@ -177,7 +177,7 @@ flutter_pilot test examples/smoke_scenario.yaml \ Stop after a specific Step and print captured diagnostic context: ```bash -flutter_pilot test examples/smoke_scenario.yaml \ +flutter_pilot test examples/smoke_app/smoke_scenario.yaml \ --until wait_for_error \ --print snapshot ``` diff --git a/README_zh.md b/README_zh.md index 61a2515..4c9120a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -134,13 +134,13 @@ stdout,方便脚本读取。 不连接 Flutter 应用,只校验 Scenario: ```bash -flutter_pilot validate examples/smoke_scenario.yaml +flutter_pilot validate examples/smoke_app/smoke_scenario.yaml ``` 通过启动当前 Target App Package 运行 Scenario: ```bash -flutter_pilot test examples/smoke_scenario.yaml +flutter_pilot test examples/smoke_app/smoke_scenario.yaml ``` 运行默认 Pilot Directory(`pilot/`)下的全部 Project Scenarios: @@ -158,7 +158,7 @@ flutter_pilot test pilot/regression 需要时可以选择 Target Device、Flutter flavor 或应用入口文件: ```bash -flutter_pilot test examples/smoke_scenario.yaml \ +flutter_pilot test examples/smoke_app/smoke_scenario.yaml \ --device \ --flavor staging \ --target lib/main_staging.dart @@ -167,7 +167,7 @@ flutter_pilot test examples/smoke_scenario.yaml \ 运行到指定 Step 后停止,并打印捕获到的诊断上下文: ```bash -flutter_pilot test examples/smoke_scenario.yaml \ +flutter_pilot test examples/smoke_app/smoke_scenario.yaml \ --until wait_for_error \ --print snapshot ``` diff --git a/packages/pilot_runtime/lib/src/finder_resolver.dart b/packages/pilot_runtime/lib/src/finder_resolver.dart index 09acd60..fdf8f4c 100644 --- a/packages/pilot_runtime/lib/src/finder_resolver.dart +++ b/packages/pilot_runtime/lib/src/finder_resolver.dart @@ -102,6 +102,11 @@ class PilotRuntimeFinderResolver { _visitVisibleElements(rootElement, visitor); } + /// Return whether one Element satisfies Finder visibility rules. + static bool isVisibleElement(Element element) { + return _isVisible(element); + } + static void _visitVisibleElements( Element element, void Function(Element element) visitor, diff --git a/packages/pilot_runtime/lib/src/scroll_performer.dart b/packages/pilot_runtime/lib/src/scroll_performer.dart index 964a40e..0e14973 100644 --- a/packages/pilot_runtime/lib/src/scroll_performer.dart +++ b/packages/pilot_runtime/lib/src/scroll_performer.dart @@ -111,7 +111,8 @@ class PilotRuntimeScrollPerformer { if (element == null) { return null; } - if (element.widget is Scrollable) { + if (PilotRuntimeFinderResolver.isVisibleElement(element) && + element.widget is Scrollable) { return element; } final List scrollables = []; @@ -128,6 +129,9 @@ class PilotRuntimeScrollPerformer { Element element, List scrollables, ) { + if (!PilotRuntimeFinderResolver.isVisibleElement(element)) { + return; + } if (element.widget is Scrollable) { scrollables.add(element); } diff --git a/packages/pilot_runtime/lib/src/text_performer.dart b/packages/pilot_runtime/lib/src/text_performer.dart index adeb96e..a7cd24c 100644 --- a/packages/pilot_runtime/lib/src/text_performer.dart +++ b/packages/pilot_runtime/lib/src/text_performer.dart @@ -54,33 +54,31 @@ class PilotRuntimeTextPerformer { 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); - }); - final EditableTextState? editableState = state; - if (editableState == null || !_isEditable(editableState)) { + final List states = []; + _collectEditableStates(element, states); + if (states.length != 1) { return null; } - return editableState; + return states.single; } - static EditableTextState? _editableStateFromElement(Element element) { + static void _collectEditableStates( + Element element, + List states, + ) { + if (!PilotRuntimeFinderResolver.isVisibleElement(element)) { + return; + } if (element is StatefulElement && element.state is EditableTextState) { final EditableTextState state = element.state as EditableTextState; if (_isEditable(state)) { - return state; + states.add(state); } - return null; + return; } - EditableTextState? state; element.visitChildren((Element child) { - state ??= _editableStateFromElement(child); + _collectEditableStates(child, states); }); - return state; } static bool _isEditable(EditableTextState state) { diff --git a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index ecb7960..9f9600f 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -637,6 +637,50 @@ void main() { expect(controller.text, 'locked'); }); + testWidgets('type fails for a wrapper with multiple editable descendants', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final TextEditingController firstController = TextEditingController( + text: 'first', + ); + final TextEditingController secondController = TextEditingController( + text: 'second', + ); + addTearDown(firstController.dispose); + addTearDown(secondController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + key: const ValueKey('credentials'), + children: [ + TextField(controller: firstController), + TextField(controller: secondController), + ], + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byKey: 'credentials', + ); + final Map match = _singleMatch(response); + + final Map clearResponse = await _clearText( + extensions, + handle: match['handle']! as String, + ); + + expect(clearResponse['ok'], false); + expect(clearResponse['code'], 'notEditableText'); + expect(firstController.text, 'first'); + expect(secondController.text, 'second'); + }); + testWidgets('wrapper evidence ignores hidden descendant text and types', ( WidgetTester tester, ) async { @@ -786,6 +830,73 @@ void main() { expect(scrollResponse['message'], contains('scrollable')); }); + testWidgets('targeted scroll ignores hidden descendant scrollables', ( + WidgetTester tester, + ) async { + final Map extensions = + _registerRuntimeExtensions(); + final ScrollController visibleController = ScrollController(); + final ScrollController hiddenController = ScrollController(); + addTearDown(visibleController.dispose); + addTearDown(hiddenController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + key: const ValueKey('scroll_wrapper'), + children: [ + Offstage( + child: SizedBox( + height: 200, + child: ListView.builder( + controller: hiddenController, + itemCount: 30, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + height: 48, + child: Text('Hidden $index'), + ); + }, + ), + ), + ), + Expanded( + child: ListView.builder( + controller: visibleController, + itemCount: 30, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + height: 48, + child: Text('Visible $index'), + ); + }, + ), + ), + ], + ), + ), + ), + ); + + final Map response = await _resolveFinder( + extensions, + byKey: 'scroll_wrapper', + ); + 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(visibleController.offset, greaterThan(0)); + expect(hiddenController.offset, 0); + }); + testWidgets('scroll drags the primary scrollable when no handle is given', ( WidgetTester tester, ) async { From 2da7f85bd5944759330a58a3286733bedde38217 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 09:36:39 +0800 Subject: [PATCH 10/15] no-mistakes(review): Restore smoke capture coverage and docs --- docs-internal/scenario-yaml.md | 6 +- examples/smoke_app/smoke_scenario.yaml | 3 + lib/src/smoke_verifier.dart | 61 ++++++++++++++++- test/scenario_yaml_docs_test.dart | 15 ++++ test/smoke_scenario_contract_test.dart | 14 +++- test/smoke_script_test.dart | 95 ++++++++++++++++++++------ 6 files changed, 166 insertions(+), 28 deletions(-) create mode 100644 test/scenario_yaml_docs_test.dart diff --git a/docs-internal/scenario-yaml.md b/docs-internal/scenario-yaml.md index 4f353e0..25f1d56 100644 --- a/docs-internal/scenario-yaml.md +++ b/docs-internal/scenario-yaml.md @@ -327,7 +327,7 @@ and app entrypoint choices stay in CLI options so Scenarios remain portable across machines, devices, and CI. ```bash -flutter_pilot test examples/smoke_scenario.yaml --device pixel-8 +flutter_pilot test examples/smoke_app/smoke_scenario.yaml --device pixel-8 ``` `test --target` selects the Flutter app entrypoint file, such as @@ -363,11 +363,11 @@ Flutter Pilot validates Scenario YAML strictly: Run validation without connecting to a Flutter app: ```bash -flutter_pilot validate examples/smoke_scenario.yaml +flutter_pilot validate examples/smoke_app/smoke_scenario.yaml ``` Use JSON output for tools: ```bash -flutter_pilot validate examples/smoke_scenario.yaml --json +flutter_pilot validate examples/smoke_app/smoke_scenario.yaml --json ``` diff --git a/examples/smoke_app/smoke_scenario.yaml b/examples/smoke_app/smoke_scenario.yaml index dd0df04..4990fa3 100644 --- a/examples/smoke_app/smoke_scenario.yaml +++ b/examples/smoke_app/smoke_scenario.yaml @@ -23,3 +23,6 @@ steps: waitFor: byText: Smoke validation failed timeoutMs: 5000 + + - label: capture_error + capture: {} diff --git a/lib/src/smoke_verifier.dart b/lib/src/smoke_verifier.dart index c736097..d896f88 100644 --- a/lib/src/smoke_verifier.dart +++ b/lib/src/smoke_verifier.dart @@ -46,17 +46,24 @@ class SmokeVerificationResult { /// It reads the `run_report.json` written by `flutter_pilot test` and checks the /// current real Finder integration contract: /// - the run status is `passed` -/// - the Finder Steps in `examples/smoke_scenario.yaml` passed +/// - the Finder Steps in `examples/smoke_app/smoke_scenario.yaml` passed +/// - the explicit capture Step passed and wrote the default capture bundle class SmokeRunVerifier { SmokeRunVerifier._(); static const String runReportPrefix = 'Run report:'; + static const String _captureStepLabel = 'capture_error'; static const Set _finderStepLabels = { 'wait_for_smoke_form', 'enter_email', 'submit_form', 'wait_for_error', }; + static const Set _requiredCaptureArtifactTypes = { + 'screenshot', + 'widgetTree', + 'logs', + }; /// Return the report path printed by `flutter_pilot test`. /// @@ -120,6 +127,8 @@ class SmokeRunVerifier { _verifyRunStatus(decoded, errors); final List> steps = _reportSteps(decoded, errors); _verifyFinderSteps(steps, errors); + _verifyCaptureStep(steps, errors); + _verifyCaptureArtifacts(decoded, errors); return SmokeVerificationResult(reportPath: reportFile.path, errors: errors); } @@ -169,6 +178,56 @@ class SmokeRunVerifier { } } + /// Verify the smoke Scenario Step that exercises capture artifact writing. + static void _verifyCaptureStep( + List> steps, + List errors, + ) { + final Map? step = _stepByLabel(steps, _captureStepLabel); + if (step == null) { + errors.add('Missing capture Step: $_captureStepLabel.'); + return; + } + if (step['status'] != 'passed') { + errors.add( + 'Expected capture Step $_captureStepLabel to pass, got ' + '${step['status']}.', + ); + } + } + + /// Verify that the explicit capture Step wrote the default artifact bundle. + static void _verifyCaptureArtifacts( + Map report, + List errors, + ) { + final Object? artifacts = report['artifacts']; + if (artifacts is! List) { + errors.add('Expected run report artifacts to be a list.'); + return; + } + + final Set captureArtifactTypes = {}; + for (final Object? artifact in artifacts) { + if (artifact is! Map) { + continue; + } + if (artifact['purpose'] != 'capture') { + continue; + } + final Object? type = artifact['type']; + if (type is String) { + captureArtifactTypes.add(type); + } + } + + for (final String requiredType in _requiredCaptureArtifactTypes) { + if (!captureArtifactTypes.contains(requiredType)) { + errors.add('Missing capture artifact type: $requiredType.'); + } + } + } + /// Return the Step with the requested label. static Map? _stepByLabel( List> steps, diff --git a/test/scenario_yaml_docs_test.dart b/test/scenario_yaml_docs_test.dart new file mode 100644 index 0000000..67e201a --- /dev/null +++ b/test/scenario_yaml_docs_test.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +/// Verifies that internal Scenario YAML command examples reference live files. +void main() { + test('internal Scenario YAML docs use the current smoke Scenario path', () { + final String markdown = File( + 'docs-internal/scenario-yaml.md', + ).readAsStringSync(); + + expect(markdown, contains('examples/smoke_app/smoke_scenario.yaml')); + expect(markdown, isNot(contains('examples/smoke_scenario.yaml'))); + }); +} diff --git a/test/smoke_scenario_contract_test.dart b/test/smoke_scenario_contract_test.dart index 4a69cef..68fc2c4 100644 --- a/test/smoke_scenario_contract_test.dart +++ b/test/smoke_scenario_contract_test.dart @@ -2,10 +2,10 @@ import 'package:flutter_pilot/flutter_pilot.dart'; import 'package:test/test.dart'; /// Verifies that the checked-in real-runtime smoke Scenario keeps exercising -/// the supported Finder contract. +/// the supported Finder and capture contracts. void main() { test( - 'example smoke Scenario includes byText, byType, and combined Finder', + 'example smoke Scenario includes byText, byType, combined Finder, and capture', () { final Scenario scenario = ScenarioParser.parseFile( 'examples/smoke_app/smoke_scenario.yaml', @@ -40,7 +40,15 @@ void main() { expect(waitFor.finder.byText, 'Smoke validation failed'); expect(waitFor.finder.byType, isNull); - expect(scenario.steps, hasLength(4)); + final CaptureAction capture = _actionWithLabel( + scenario, + 'capture_error', + ); + expect(capture.screenshot, isTrue); + expect(capture.widgetTree, isTrue); + expect(capture.logs, isTrue); + + expect(scenario.steps, hasLength(5)); }, ); } diff --git a/test/smoke_script_test.dart b/test/smoke_script_test.dart index 0831a98..b90676e 100644 --- a/test/smoke_script_test.dart +++ b/test/smoke_script_test.dart @@ -19,66 +19,99 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html ); }); - test('passes when required smoke Steps are valid', () { + test( + 'passes when required smoke Steps and capture artifacts are valid', + () { + final Directory runDirectory = Directory.systemTemp.createTempSync( + 'flutter_pilot_smoke_report_', + ); + try { + final File reportFile = _writeSmokeReport( + runDirectory: runDirectory, + status: 'passed', + stepStatuses: const { + 'wait_for_smoke_form': 'passed', + 'enter_email': 'passed', + 'submit_form': 'passed', + 'wait_for_error': 'passed', + 'capture_error': 'passed', + }, + artifactTypes: const {'screenshot', 'widgetTree', 'logs'}, + ); + + final SmokeVerificationResult result = + SmokeRunVerifier.verifyReportFile(reportFile); + + expect(result.passed, isTrue); + expect(result.errors, isEmpty); + } finally { + runDirectory.deleteSync(recursive: true); + } + }, + ); + + test('fails when a Finder Step failed', () { final Directory runDirectory = Directory.systemTemp.createTempSync( 'flutter_pilot_smoke_report_', ); try { final File reportFile = _writeSmokeReport( runDirectory: runDirectory, - status: 'passed', + status: 'failed', stepStatuses: const { 'wait_for_smoke_form': 'passed', 'enter_email': 'passed', - 'submit_form': 'passed', - 'wait_for_error': 'passed', + 'submit_form': 'failed', + 'wait_for_error': 'skipped', + 'capture_error': 'skipped', }, ); final SmokeVerificationResult result = SmokeRunVerifier.verifyReportFile(reportFile); - expect(result.passed, isTrue); - expect(result.errors, isEmpty); + expect(result.passed, isFalse); + expect( + result.errors, + contains('Expected run status passed, got failed.'), + ); + expect( + result.errors, + contains('Expected Finder Step submit_form to pass, got failed.'), + ); } finally { runDirectory.deleteSync(recursive: true); } }); - test('fails when a Finder Step failed', () { + test('fails when a required smoke Step is missing', () { final Directory runDirectory = Directory.systemTemp.createTempSync( 'flutter_pilot_smoke_report_', ); try { final File reportFile = _writeSmokeReport( runDirectory: runDirectory, - status: 'failed', + status: 'passed', stepStatuses: const { 'wait_for_smoke_form': 'passed', 'enter_email': 'passed', - 'submit_form': 'failed', - 'wait_for_error': 'skipped', + 'submit_form': 'passed', + 'capture_error': 'passed', }, + artifactTypes: const {'screenshot', 'widgetTree', 'logs'}, ); final SmokeVerificationResult result = SmokeRunVerifier.verifyReportFile(reportFile); expect(result.passed, isFalse); - expect( - result.errors, - contains('Expected run status passed, got failed.'), - ); - expect( - result.errors, - contains('Expected Finder Step submit_form to pass, got failed.'), - ); + expect(result.errors, contains('Missing Finder Step: wait_for_error.')); } finally { runDirectory.deleteSync(recursive: true); } }); - test('fails when a required smoke Step is missing', () { + test('fails when capture artifacts are missing', () { final Directory runDirectory = Directory.systemTemp.createTempSync( 'flutter_pilot_smoke_report_', ); @@ -90,14 +123,20 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html 'wait_for_smoke_form': 'passed', 'enter_email': 'passed', 'submit_form': 'passed', + 'wait_for_error': 'passed', + 'capture_error': 'passed', }, + artifactTypes: const {'screenshot', 'logs'}, ); final SmokeVerificationResult result = SmokeRunVerifier.verifyReportFile(reportFile); expect(result.passed, isFalse); - expect(result.errors, contains('Missing Finder Step: wait_for_error.')); + expect( + result.errors, + contains('Missing capture artifact type: widgetTree.'), + ); } finally { runDirectory.deleteSync(recursive: true); } @@ -110,6 +149,7 @@ File _writeSmokeReport({ required Directory runDirectory, required String status, required Map stepStatuses, + Set artifactTypes = const {}, }) { final File reportFile = File('${runDirectory.path}/run_report.json'); reportFile.writeAsStringSync( @@ -119,7 +159,14 @@ File _writeSmokeReport({ 'startedAt': '2026-06-19T10:20:00.000Z', 'durationMs': 42, 'runDirectory': runDirectory.path, - 'artifacts': const [], + 'artifacts': [ + for (final String artifactType in artifactTypes) + { + 'type': artifactType, + 'path': 'captures/0005_capture_error_$artifactType.json', + 'purpose': 'capture', + }, + ], 'steps': [ if (stepStatuses.containsKey('wait_for_smoke_form')) _stepJson( @@ -137,6 +184,12 @@ File _writeSmokeReport({ action: 'waitFor', status: stepStatuses, ), + if (stepStatuses.containsKey('capture_error')) + _stepJson( + label: 'capture_error', + action: 'capture', + status: stepStatuses, + ), ], }), ); From 8277d4ada0c135c503febf7fc9e61556038e1747 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 09:52:30 +0800 Subject: [PATCH 11/15] no-mistakes(review): Verify smoke capture artifact files --- lib/src/smoke_verifier.dart | 25 ++++++++++++++++---- test/smoke_script_test.dart | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/lib/src/smoke_verifier.dart b/lib/src/smoke_verifier.dart index d896f88..04946c9 100644 --- a/lib/src/smoke_verifier.dart +++ b/lib/src/smoke_verifier.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:path/path.dart' as p; + /// Exit codes used by the real Runtime Adapter smoke verifier. /// /// These codes keep CI failures distinguishable: @@ -128,7 +130,7 @@ class SmokeRunVerifier { final List> steps = _reportSteps(decoded, errors); _verifyFinderSteps(steps, errors); _verifyCaptureStep(steps, errors); - _verifyCaptureArtifacts(decoded, errors); + _verifyCaptureArtifacts(reportFile, decoded, errors); return SmokeVerificationResult(reportPath: reportFile.path, errors: errors); } @@ -198,6 +200,7 @@ class SmokeRunVerifier { /// Verify that the explicit capture Step wrote the default artifact bundle. static void _verifyCaptureArtifacts( + File reportFile, Map report, List errors, ) { @@ -207,7 +210,8 @@ class SmokeRunVerifier { return; } - final Set captureArtifactTypes = {}; + final Map> captureArtifactPaths = + >{}; for (final Object? artifact in artifacts) { if (artifact is! Map) { continue; @@ -216,14 +220,25 @@ class SmokeRunVerifier { continue; } final Object? type = artifact['type']; - if (type is String) { - captureArtifactTypes.add(type); + final Object? path = artifact['path']; + if (type is String && path is String) { + captureArtifactPaths.putIfAbsent(type, () => []).add(path); } } for (final String requiredType in _requiredCaptureArtifactTypes) { - if (!captureArtifactTypes.contains(requiredType)) { + final List? artifactPaths = captureArtifactPaths[requiredType]; + if (artifactPaths == null || artifactPaths.isEmpty) { errors.add('Missing capture artifact type: $requiredType.'); + continue; + } + for (final String artifactPath in artifactPaths) { + final File artifactFile = File( + p.join(reportFile.parent.path, artifactPath), + ); + if (!artifactFile.existsSync()) { + errors.add('Missing capture artifact file: $artifactPath.'); + } } } } diff --git a/test/smoke_script_test.dart b/test/smoke_script_test.dart index b90676e..3392885 100644 --- a/test/smoke_script_test.dart +++ b/test/smoke_script_test.dart @@ -141,6 +141,41 @@ HTML report: .runs/2026-06-19_10-20_smoke_runtime/timeline.html runDirectory.deleteSync(recursive: true); } }); + + test('fails when a listed capture artifact file is missing', () { + final Directory runDirectory = Directory.systemTemp.createTempSync( + 'flutter_pilot_smoke_report_', + ); + try { + final File reportFile = _writeSmokeReport( + runDirectory: runDirectory, + status: 'passed', + stepStatuses: const { + 'wait_for_smoke_form': 'passed', + 'enter_email': 'passed', + 'submit_form': 'passed', + 'wait_for_error': 'passed', + 'capture_error': 'passed', + }, + artifactTypes: const {'screenshot', 'widgetTree', 'logs'}, + missingArtifactTypes: const {'widgetTree'}, + ); + + final SmokeVerificationResult result = + SmokeRunVerifier.verifyReportFile(reportFile); + + expect(result.passed, isFalse); + expect( + result.errors, + contains( + 'Missing capture artifact file: ' + 'captures/0005_capture_error_widgetTree.json.', + ), + ); + } finally { + runDirectory.deleteSync(recursive: true); + } + }); }); } @@ -150,8 +185,19 @@ File _writeSmokeReport({ required String status, required Map stepStatuses, Set artifactTypes = const {}, + Set missingArtifactTypes = const {}, }) { final File reportFile = File('${runDirectory.path}/run_report.json'); + final Directory captureDirectory = Directory('${runDirectory.path}/captures') + ..createSync(recursive: true); + for (final String artifactType in artifactTypes) { + if (missingArtifactTypes.contains(artifactType)) { + continue; + } + File( + '${captureDirectory.path}/0005_capture_error_$artifactType.json', + ).writeAsStringSync('{}'); + } reportFile.writeAsStringSync( const JsonEncoder.withIndent(' ').convert({ 'scenario': {'name': 'smoke_runtime'}, From 5866e7b23ca23fb0a03727df9e4f76805aa6df3d Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 10:50:01 +0800 Subject: [PATCH 12/15] no-mistakes(review): Restore MCP smoke scroll compatibility --- examples/smoke_app/README.md | 2 +- .../runtime/mcp_flutter_runtime_adapter.dart | 10 ++++---- test/mcp_flutter_runtime_adapter_test.dart | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/examples/smoke_app/README.md b/examples/smoke_app/README.md index 320a7cf..313b996 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 `pilot_runtime` smoke path. +This is the minimal Flutter app used by the real `mcp_flutter` smoke path. Run it in debug mode: diff --git a/lib/src/runtime/mcp_flutter_runtime_adapter.dart b/lib/src/runtime/mcp_flutter_runtime_adapter.dart index 1d1fad3..09293f6 100644 --- a/lib/src/runtime/mcp_flutter_runtime_adapter.dart +++ b/lib/src/runtime/mcp_flutter_runtime_adapter.dart @@ -405,11 +405,11 @@ class McpFlutterRuntimeAdapter implements RuntimeAdapter { return deltaX < 0 ? 'right' : 'left'; } - static double _scrollDistance({ - required double deltaX, - required double deltaY, - }) { - return deltaY.abs() >= deltaX.abs() ? deltaY.abs() : deltaX.abs(); + static int _scrollDistance({required double deltaX, required double deltaY}) { + final double distance = deltaY.abs() >= deltaX.abs() + ? deltaY.abs() + : deltaX.abs(); + return distance.round(); } static String? _firstScreenshotImage(Object? data) { diff --git a/test/mcp_flutter_runtime_adapter_test.dart b/test/mcp_flutter_runtime_adapter_test.dart index ab0408a..b2e4de7 100644 --- a/test/mcp_flutter_runtime_adapter_test.dart +++ b/test/mcp_flutter_runtime_adapter_test.dart @@ -119,6 +119,30 @@ void main() { }, ); + test('rounds scroll distance for toolkit command compatibility', () 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': {}}; + }, + ); + + await adapter.performScroll( + match: const FinderMatch(id: 'scrollable-1'), + deltaX: 0, + deltaY: -250.7, + ); + + expect(calls.single.name, 'scroll'); + expect(calls.single.arguments['direction'], 'down'); + expect(calls.single.arguments['distance'], 251); + expect(calls.single.arguments['ref'], 'scrollable-1'); + }); + test('matches alternate semantic snapshot response shapes', () async { final McpFlutterRuntimeAdapter adapter = McpFlutterRuntimeAdapter( target: RuntimeTarget( From f9c8fb77129f293f301b937eb9af1b0847d21af4 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 11:17:08 +0800 Subject: [PATCH 13/15] no-mistakes(document): Sync runtime documentation with code --- README.md | 9 ++--- README_zh.md | 9 ++--- docs-internal/flutter-pilot-prd.md | 47 +++++++++++----------- docs-internal/scenario-yaml.md | 34 +++++++++++----- docs/guide/write-scenario.md | 7 +++- docs/reference/scenario-dsl.md | 5 ++- lib/src/runtime/pilot_runtime_adapter.dart | 6 +-- lib/src/runtime/runtime_contract.dart | 6 +-- lib/src/scenario.dart | 4 +- 9 files changed, 73 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 0df2f6b..d4eb355 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ debugging context. It lets you describe a UI path in YAML, replay that Scenario against a running Flutter app, and collect the context needed to understand what happened: -actions, screenshots, semantic Snapshots, logs, run reports, and timeline +actions, screenshots, Widget Trees, 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. @@ -179,7 +179,7 @@ Stop after a specific Step and print captured diagnostic context: ```bash flutter_pilot test examples/smoke_app/smoke_scenario.yaml \ --until wait_for_error \ - --print snapshot + --print widget-tree ``` Regenerate the HTML timeline from an existing run directory: @@ -217,14 +217,14 @@ flutter_pilot test flutter_pilot test --device flutter_pilot test --flavor --target flutter_pilot test --until -flutter_pilot test --until --print +flutter_pilot test --until --print flutter_pilot report flutter_pilot diff flutter_pilot diff --json ``` `--print` may be repeated. When several diagnostics are requested, Flutter Pilot -prints them in a stable order: Snapshot, Widget Tree, then errors. +prints them in a stable order: Widget Tree, then errors. With no Scenario file, `test` discovers Project Scenarios under `pilot/`. With a directory argument, it discovers Project Scenarios under that directory. @@ -248,7 +248,6 @@ batch run directory under `.runs/` and child Scenario Run directories inside it. The artifact model is designed for both human review and machine consumption. - Screenshot: what a user saw on screen. -- Snapshot: structured UI state for tools and AI agents. - Widget Tree: deeper Flutter hierarchy data when requested. - Logs: runtime and diagnostic output. - Device Video Recording: optional run-level video saved when diff --git a/README_zh.md b/README_zh.md index 4c9120a..b11dbc9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -29,7 +29,7 @@ Runtime Target 的连接信息不会写进 YAML。相同的 Scenario 可以被 提交到仓库,并通过 CLI 参数在不同的 Runtime Target 上运行。 一次运行会在 `.runs/` 下生成运行目录。这个目录的目标是独立可读:开发者可以 -查看截图和 HTML timeline,CI 可以归档产物,AI 代理可以读取结构化 Snapshot、 +查看截图和 HTML timeline,CI 可以归档产物,AI 代理可以读取结构化 Widget Tree、 日志和 `run_report.json` 作为紧凑的调试上下文。 ## 为什么需要它 @@ -169,7 +169,7 @@ flutter_pilot test examples/smoke_app/smoke_scenario.yaml \ ```bash flutter_pilot test examples/smoke_app/smoke_scenario.yaml \ --until wait_for_error \ - --print snapshot + --print widget-tree ``` 基于已有运行目录重新生成 HTML timeline: @@ -206,14 +206,14 @@ flutter_pilot test flutter_pilot test --device flutter_pilot test --flavor --target flutter_pilot test --until -flutter_pilot test --until --print +flutter_pilot test --until --print flutter_pilot report flutter_pilot diff flutter_pilot diff --json ``` `--print` 可以重复传入。请求多个诊断输出时,Flutter Pilot 会按稳定顺序打印: -Snapshot、Widget Tree、errors。 +Widget Tree、errors。 不传 Scenario 文件时,`test` 会从 `pilot/` 发现 Project Scenarios。传入目录时, 它会从该目录发现 Project Scenarios。目录发现会递归扫描 `.yaml` 和 `.yml` 文件, @@ -235,7 +235,6 @@ Scenario Run 会在 `.runs/` 下写入一个运行目录。Project Run 会在 `. 人工审查和机器消费。 - Screenshot:用户在屏幕上看到的画面。 -- Snapshot:供工具和 AI 代理消费的结构化 UI 状态。 - Widget Tree:按需捕获的更深层 Flutter 层级数据。 - Logs:运行时和诊断输出。 - Device Video Recording:启用 `scenario.recording` 时保存的可选运行级视频, diff --git a/docs-internal/flutter-pilot-prd.md b/docs-internal/flutter-pilot-prd.md index 2e25db8..3f3e769 100644 --- a/docs-internal/flutter-pilot-prd.md +++ b/docs-internal/flutter-pilot-prd.md @@ -8,7 +8,7 @@ The project should provide a deterministic UI replay and diagnostics harness for ## Solution -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. +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, Widget Trees, 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 @@ -22,7 +22,7 @@ the Flutter app entrypoint file, while `--device` selects the Target Device and The first product milestone focuses on six capabilities: 1. YAML replay -2. Step screenshots and semantic snapshots +2. Step screenshots and Widget Trees 3. Failure artifact bundle 4. `--until` and `--print` debugging controls 5. HTML timeline report @@ -45,16 +45,16 @@ The result is a reproducible bug report package that can be consumed by humans, 11. As a Flutter developer, I want to stop a run at a named step, so that I can inspect the app at the exact point where a bug begins. 12. As a Flutter developer, I want to stop a run at a numeric step, so that quick ad hoc debugging does not require editing YAML. 13. As a Flutter developer, I want to print the current widget information after a chosen step, so that I can diagnose layout and state issues from the terminal. -14. As a Flutter developer, I want to print the current semantic snapshot after a chosen step, so that I can give an AI agent compact UI context. +14. As a Flutter developer, I want to print the current Widget Tree after a chosen step, so that I can give an AI agent compact UI context. 15. As a Flutter developer, I want to capture a screenshot after any step, so that visual state is recorded alongside the action that produced it. 16. As a Flutter developer, I want screenshots to be stored with step numbers and labels, so that artifacts are easy to navigate. -17. As a Flutter developer, I want semantic snapshots to be stored with step numbers and labels, so that AI agents can reason about the visible UI. +17. As a Flutter developer, I want Widget Trees to be stored with step numbers and labels, so that AI agents can reason about the visible UI. 18. As a Flutter developer, I want widget summaries instead of only raw widget tree dumps, so that diagnostic output is readable and focused. 19. As a Flutter developer, I want logs collected during the run, including runtime errors when available, so that assertions, debug output, and failures can be correlated with UI steps. 20. As a Flutter developer, I want runtime failures represented in the same diagnostic stream as logs, so that I do not need to reason about overlapping artifact types in the first version. 21. As a Flutter developer, I want the tool to automatically collect diagnostics when a step fails, so that I do not need to predict where to add captures. 22. As a Flutter developer, I want a failure artifact bundle, so that I can attach a complete reproduction package to an issue. -23. As a Flutter developer, I want the artifact bundle to include the scenario, screenshots, snapshots, logs, device metadata, and run report, so that debugging context is portable. +23. As a Flutter developer, I want the artifact bundle to include the scenario, screenshots, Widget Trees, logs, device metadata, and run report, so that debugging context is portable. 24. As a Flutter developer, I want a structured run report, so that other tools and AI agents can parse the result. 25. As a Flutter developer, I want an HTML timeline report, so that I can review a run visually without reading JSON. 26. As a Flutter developer, I want each timeline entry to show the action, status, duration, screenshot, and diagnostics, so that failures are easy to inspect. @@ -94,8 +94,8 @@ The result is a reproducible bug report package that can be consumed by humans, - `flutter_pilot test --device ` - `flutter_pilot test --flavor --target ` - `flutter_pilot test --until ` - - `flutter_pilot test --until --print ` - - `flutter_pilot test --until --print --json` + - `flutter_pilot test --until --print ` + - `flutter_pilot test --until --print --json` - `flutter_pilot report ` - `flutter_pilot diff ` - The `validate` command checks Scenario YAML and schema rules without connecting to a Runtime Target. @@ -111,7 +111,7 @@ The result is a reproducible bug report package that can be consumed by humans, - Unknown fields are validation errors. The Scenario DSL uses a strict schema so typos and unsupported options fail clearly instead of being ignored. - `scenario.description` is optional metadata. When present, it must be a string and may use YAML multiline string syntax. - `scenario.name`, when present, must match `^[a-zA-Z0-9][a-zA-Z0-9_-]*$`. This keeps run directory names portable and prevents path-like values. Names derived from file names must be sanitized to the same safe form. -- The YAML scenario format is the product contract. It should support scenario metadata, ordered steps, optional labels, capture directives, and Finders by text and semantic node type. Multiple `by*` Finder constraints may be used in one step, and all constraints must match. Each `byText` and `byType` value is a single string, not an array. The YAML does not expose a `match` option. Runtime target configuration is produced by the `test` launch flow, while Target Device, flavor, and entrypoint choices are CLI options so scenarios remain portable across machines, devices, and CI. +- The YAML scenario format is the product contract. It should support scenario metadata, ordered steps, optional labels, capture directives, and Finders by text, semantic node type, `ValueKey`, and Dart widget runtime type. Multiple `by*` Finder constraints may be used in one step, and all constraints must match. Each Finder field value is a single string, not an array. The YAML does not expose a `match` option. Runtime target configuration is produced by the `test` launch flow, while Target Device, flavor, and entrypoint choices are CLI options so scenarios remain portable across machines, devices, and CI. - Each Step is one item in the `steps` array. A Step may include a `label` field and must include exactly one action field. Items in `steps` may also be Step Includes (`include:` key) that reference Step Library YAML files; includes are expanded at parse time into a flat Step list. The `label` field is a sibling of the action field, not a nested action parameter. - The typed action model uses a sealed class hierarchy so each Step has exactly one strongly typed action and runner/report code can handle actions exhaustively. - Finder is a typed value object shared by actions. `tap`, `type`, and `waitFor` require at least one Finder field; `scroll` may omit Finder; `capture` does not use Finder. @@ -121,7 +121,7 @@ The result is a reproducible bug report package that can be consumed by humans, - `--until ` executes through the target Step and stops after that Step completes. - Numeric `--until` values are 1-based Step numbers. - `--print` must be used with `--until` in the first version. It prints the requested diagnostics after the target Step completes. -- First-version `--print` values are `snapshot`, `widget-tree`, and `errors`. The option may be repeated to print several diagnostics after the same stopped Step. When multiple diagnostics are requested, output order is fixed as `snapshot`, `widget-tree`, then `errors`. By default, `--print` renders human-readable diagnostic text. With `--json`, it prints the raw diagnostic payload as indented JSON. Screenshots are file artifacts and are not printed to stdout. +- First-version `--print` values are `widget-tree` and `errors`. The option may be repeated to print several diagnostics after the same stopped Step. When multiple diagnostics are requested, output order is fixed as `widget-tree`, then `errors`. By default, `--print` renders human-readable diagnostic text. With `--json`, it prints the raw diagnostic payload as indented JSON. Screenshots are file artifacts and are not printed to stdout. - `test` prints Target App Launch Progress before Step progress for human-readable executions. Launch progress includes launch choices when known, elapsed launch time, heartbeat output for non-interactive runs, a final launch success summary, and a concise failure summary with the buffered Flutter stderr tail when launch fails. - `test` prints concise Step progress after the Runtime Target is available for human-readable executions. Progress includes Scenario name, Step count, optional `--until` target, Step number, action, Step label or `-` when unlabeled, status, duration, concise failure reason when a Step fails, and include source file path (e.g. `[flows/login.yaml]`) for Steps expanded from Step Libraries. - Target App Launch Progress and Step progress are presentation behavior owned by the CLI, not Scenario semantics, artifact format, or Runtime Adapter contract. The launcher and runner should expose progress events or callbacks so CLI rendering stays separate from execution. @@ -138,8 +138,9 @@ 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 `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. +- `byKey` accepts visible `ValueKey` values. It does not accept `ValueKey`, `ObjectKey`, `GlobalKey`, or private object keys. +- `byWidget` accepts exact Dart widget runtime type display names such as `FilledButton` or app-defined wrapper widget classes. +- `byType` accepts the `pilot_runtime` semantic node type, such as `textField`, `button`, `text`, `scrollable`, or `header`. It does not accept Dart widget class names such as `TextField` or `FilledButton`. - `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`. @@ -147,7 +148,7 @@ The result is a reproducible bug report package that can be consumed by humans, - 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. -- Capture directives support screenshots, semantic snapshots, widget summaries, logs, and labels. Runtime errors are collected as part of logs in the first version. +- Capture directives support screenshots, Widget Trees, logs, and labels. Runtime errors are collected as part of logs in the first version. - Failed steps automatically trigger diagnostic capture even if the YAML did not request a capture at that point. - Scenario execution produces a run directory containing a structured run report, an HTML timeline report, aggregated Step metadata, and capture artifacts. - `scenario.name` is metadata and does not need to be globally unique. Run directories use minute-level timestamp plus scenario name, such as `.runs/2026-06-06_12-30_login_error/`, to avoid overwrites and preserve chronological sorting. @@ -163,12 +164,12 @@ The result is a reproducible bug report package that can be consumed by humans, paths. The first Project Run version does not generate a batch-level HTML report. - The HTML timeline report is generated from the run report and artifacts, not by rerunning the scenario. `flutter_pilot report ` regenerates `timeline.html` from an existing run directory. -- The diff command compares two run directories. It reports changes in step status, screenshots, visible text, semantic snapshots, widget summaries, and logs. -- A Screenshot is a visual image artifact for human review. A Snapshot is a structured UI state artifact for programmatic and agent consumption. A Widget Tree is a raw or near-raw Flutter hierarchy artifact for deeper debugging. -- Snapshot capture is enabled by default for capture checkpoints. Widget Tree capture is available but disabled by default because it can be large and noisy. -- The `capture` action records diagnostic artifacts at a Step. `capture: {}` uses the default bundle: `screenshot: true`, `snapshot: true`, `widgetTree: false`, and `logs: true`. Each option can be explicitly overridden. +- The diff command compares two run directories. It reports changes in step status, screenshots, visible text, Widget Trees, and logs. +- A Screenshot is a visual image artifact for human review. A Widget Tree is structured Flutter hierarchy data for programmatic and agent consumption. +- Widget Tree capture is enabled by default for capture checkpoints. The legacy `snapshot` capture field is no longer accepted in Scenario YAML. +- The `capture` action records diagnostic artifacts at a Step. `capture: {}` uses the default bundle: `screenshot: true`, `widgetTree: true`, and `logs: true`. Each option can be explicitly overridden. - Failed Steps automatically capture the same default bundle as `capture: {}`. -- Raw Widget Tree dumps may be available, but agent-facing output should default to compact summaries of visible text, interactive widgets, routes, logs, runtime failures, and likely suspects. The Diagnostic Reducer writes this summary as `diagnosticSummary` in the run report when `--print` captures raw Snapshot, Widget Tree, or error diagnostics. +- Raw Widget Tree dumps may be available, but agent-facing output should default to compact summaries of visible text, interactive widgets, routes, logs, runtime failures, and likely suspects. The Diagnostic Reducer writes this summary as `diagnosticSummary` in the run report when `--print` captures Widget Tree or error diagnostics. - 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. @@ -189,23 +190,23 @@ The result is a reproducible bug report package that can be consumed by humans, - 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. `PilotRuntimeAdapter` contract - - Define the narrow interface for tap, type, scroll, wait, screenshot, semantic snapshot, widget data, and logs. + - Define the narrow interface for tap, type, scroll, wait, screenshot, Widget Tree 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 `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. + - 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, Widget Tree data, Logs, Finder by text/key/type/widget, 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`. 4. Artifact store and structured run report - Create stable run directories, persist scenario copies, aggregated Step metadata, command outputs, and `run_report.json`. - This should land before richer captures so every feature has a consistent place to write artifacts. -5. Capture checkpoints: screenshots and semantic snapshots +5. Capture checkpoints: screenshots and Widget Trees - Implement explicit `capture` steps and optional per-step capture configuration. - - Store screenshots and semantic snapshots with step numbers and labels, referenced from the run report. + - Store screenshots and Widget Trees with step numbers and labels, referenced from the run report. 6. Failure artifact bundle - - On failed steps, automatically collect screenshot, semantic snapshot, widget summary, logs, and device or target metadata. + - On failed steps, automatically collect screenshot, Widget Tree, logs, and device or target metadata. - This is the first major user-facing debugging win and should be complete before report polish. 7. `--until` and `--print` - - Support stopping at numeric or labeled steps and printing `snapshot`, `widget-tree`, `errors`, or any repeated combination of those values in fixed output order. + - Support stopping at numeric or labeled steps and printing `widget-tree`, `errors`, or both in fixed output order. - This builds on the runner, capture, and diagnostic plumbing and makes the tool useful for iterative human and agent debugging. 8. Diagnostic reducer - Convert raw widget, semantic, and log data into compact agent-friendly summaries of visible text, interactive widgets, routes, logs, runtime failures, and likely suspects. diff --git a/docs-internal/scenario-yaml.md b/docs-internal/scenario-yaml.md index 25f1d56..212511a 100644 --- a/docs-internal/scenario-yaml.md +++ b/docs-internal/scenario-yaml.md @@ -54,8 +54,7 @@ steps: - label: capture_failure capture: screenshot: true - snapshot: true - widgetTree: false + widgetTree: true logs: true ``` @@ -186,7 +185,7 @@ produce a validation error instead of falling back to the working directory. ## Finders A Finder identifies the widget that a Step should interact with or wait for. -Supported Finder fields are `byText` and `byType`. +Supported Finder fields are `byText`, `byType`, `byKey`, and `byWidget`. ```yaml tap: @@ -204,15 +203,30 @@ tap: byType: button ``` +```yaml +tap: + byKey: continue_button +``` + +```yaml +tap: + byWidget: PrimaryButton +``` + When several Finder fields are present, all constraints must match. There is no `match` field and no fallback order. `byText` matches exact visible text. It does not perform contains, fuzzy, or regular expression matching. -`byType` is the `mcp_flutter` semantic Snapshot node type, such as `textField`, -`button`, `text`, `scrollable`, or `header`. It is not a Dart widget class name -like `TextField` or `FilledButton`. +`byType` is the runtime semantic node type, such as `textField`, `button`, +`text`, `scrollable`, or `header`. It is not a Dart widget class name like +`TextField` or `FilledButton`. + +`byKey` matches a visible target by `ValueKey` value. + +`byWidget` matches a visible target by exact Dart widget runtime type display +name, such as `FilledButton` or an app-authored wrapper widget class. Finder fields are single strings. Arrays are validation errors. @@ -300,8 +314,7 @@ waiting until timeout. Multiple matches fail the Step. ```yaml screenshot: true -snapshot: true -widgetTree: false +widgetTree: true logs: true ``` @@ -311,11 +324,12 @@ Each option can be overridden: - label: capture_deep_context capture: screenshot: true - snapshot: true widgetTree: true logs: true ``` +The legacy `snapshot` capture field is no longer accepted in Scenario YAML. + Runtime errors are collected as part of logs in the first version. ## Runtime Target Is Not YAML @@ -347,7 +361,7 @@ Flutter Pilot validates Scenario YAML strictly: - `tap`, `type`, and `waitFor` require a Finder. - `scroll` may omit a Finder. - `capture` does not use a Finder. -- `byText` and `byType` must be single strings. +- `byText`, `byType`, `byKey`, and `byWidget` must be single strings. - Step labels must be unique (checked after include expansion). - `scroll` must have a non-zero `deltaX` or `deltaY`. - `waitFor.timeoutMs` defaults to `3000`. diff --git a/docs/guide/write-scenario.md b/docs/guide/write-scenario.md index 93cd6f4..8e02b50 100644 --- a/docs/guide/write-scenario.md +++ b/docs/guide/write-scenario.md @@ -35,8 +35,9 @@ Labels must be unique inside a Scenario. ## Find widgets by visible UI -Use `byText` for exact visible text and `byType` for semantic node types -exposed by the runtime. +Use `byText` for exact visible text, `byType` for semantic node types exposed +by the runtime, `byKey` for `ValueKey` values, and `byWidget` for exact +Dart widget runtime type display names. ```yaml scenario steps: @@ -44,6 +45,8 @@ steps: tap: byText: Continue byType: button + byKey: continue_button + byWidget: PrimaryButton ``` When a Finder has multiple fields, every configured field must match. diff --git a/docs/reference/scenario-dsl.md b/docs/reference/scenario-dsl.md index 729457d..7fd8c79 100644 --- a/docs/reference/scenario-dsl.md +++ b/docs/reference/scenario-dsl.md @@ -103,6 +103,8 @@ A Finder identifies the widget that an action should interact with or wait for. | --- | --- | | `byText` | Exact visible text. | | `byType` | Semantic node type exposed by the runtime, such as `button` or `textField`. | +| `byKey` | `ValueKey` value. | +| `byWidget` | Exact Dart widget runtime type display name, such as `FilledButton`. | ```yaml scenario steps: @@ -122,7 +124,7 @@ Flutter Pilot supports these Scenario actions: | Action | Purpose | | --- | --- | | `tap` | Tap exactly one Finder Match. | -| `type` | Replace text in exactly one Finder Match. | +| `type` | Clear and enter text in exactly one Finder Match. | | `scroll` | Drag a scrollable area by logical-pixel deltas. | | `waitFor` | Wait until a Finder resolves to exactly one match. | | `capture` | Record diagnostic artifacts at that Step. | @@ -180,3 +182,4 @@ steps: ``` `capture: {}` records the default bundle: screenshot, Widget Tree, and logs. +The legacy `snapshot` capture field is no longer accepted in Scenario YAML. diff --git a/lib/src/runtime/pilot_runtime_adapter.dart b/lib/src/runtime/pilot_runtime_adapter.dart index 1c07ea1..3616329 100644 --- a/lib/src/runtime/pilot_runtime_adapter.dart +++ b/lib/src/runtime/pilot_runtime_adapter.dart @@ -5,9 +5,9 @@ import 'runtime_contract.dart'; /// Runtime Adapter backed by the `pilot_runtime` package. /// -/// This first adapter slice verifies the app-side runtime handshake and exposes -/// Widget Tree capture through Flutter Pilot's existing Runtime Adapter -/// contract. +/// It verifies the app-side runtime handshake, maps Scenario Finders and +/// replay actions to `pilot_runtime`, and exposes Widget Tree capture through +/// Flutter Pilot's Runtime Adapter contract. class PilotRuntimeAdapter implements RuntimeAdapter { /// Create an adapter backed by a checked `pilot_runtime` client. const PilotRuntimeAdapter({ diff --git a/lib/src/runtime/runtime_contract.dart b/lib/src/runtime/runtime_contract.dart index 116a1e3..f86cb09 100644 --- a/lib/src/runtime/runtime_contract.dart +++ b/lib/src/runtime/runtime_contract.dart @@ -4,9 +4,9 @@ import '../scenario.dart'; /// Runtime Target selected for one Scenario run. /// -/// The first version accepts a Flutter VM service URI from the CLI `--target` -/// option. The target is bound when creating the real runtime adapter, rather -/// than passed to every adapter method. +/// It identifies the launched or connected app instance for the real runtime +/// adapter. The target is bound when creating the adapter, rather than passed +/// to every adapter method. class RuntimeTarget { const RuntimeTarget({required this.vmServiceUri}); diff --git a/lib/src/scenario.dart b/lib/src/scenario.dart index 2d74cb8..8d4f9a8 100644 --- a/lib/src/scenario.dart +++ b/lib/src/scenario.dart @@ -255,7 +255,7 @@ class TapAction extends StepAction { } } -/// Text-entry action that replaces the target widget's existing text. +/// Text-entry action that clears and re-enters the target widget's text. class TypeAction extends StepAction { const TypeAction({required this.finder, required this.text}); @@ -313,7 +313,7 @@ class WaitForAction extends StepAction { /// /// Each boolean controls one artifact family: /// - `screenshot`: visual image artifact -/// - `snapshot`: legacy structured UI state, no longer accepted in Scenario YAML +/// - `snapshot`: legacy structured UI state, always false for Scenario YAML /// - `widgetTree`: structured Widget Tree for programs and agents /// - `logs`: structured runtime logs, including runtime errors when available class CaptureAction extends StepAction { From 2ee6f6889edf191861666b903880528944ee7d1b Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 11:21:56 +0800 Subject: [PATCH 14/15] no-mistakes(lint): Format pilot runtime files --- .../lib/src/pilot_runtime_binding.dart | 8 ++++---- .../lib/src/pilot_runtime_client.dart | 18 ++++++------------ .../lib/src/scroll_performer.dart | 8 +++----- .../test/pilot_runtime_binding_test.dart | 18 +++++++++--------- .../test/pilot_runtime_client_test.dart | 2 +- .../test/pilot_runtime_finder_test.dart | 4 ++-- 6 files changed, 25 insertions(+), 33 deletions(-) diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart index 63703d3..49b709b 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart @@ -15,16 +15,16 @@ import 'text_performer.dart'; /// The first runtime slice only needs a zero-argument handshake request. Later /// capabilities can add their own typed request handlers without changing the /// handshake registration contract. -typedef PilotRuntimeExtensionHandler = - Future> Function(Map parameters); +typedef PilotRuntimeExtensionHandler = Future> Function( + Map parameters); /// Registers one Flutter Pilot service extension with the debug VM Service. /// /// Tests can provide a fake registrar to inspect the extension name and invoke /// the handler without launching a Flutter app. Production callers omit the /// registrar so the binding registers through `dart:developer`. -typedef PilotRuntimeExtensionRegistrar = - void Function(String extensionName, PilotRuntimeExtensionHandler handler); +typedef PilotRuntimeExtensionRegistrar = void Function( + String extensionName, PilotRuntimeExtensionHandler handler); /// App-side hook that exposes Flutter Pilot runtime service extensions. /// diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart index e187189..2304e48 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart @@ -371,8 +371,7 @@ class PilotRuntimeClient { if (handshake.protocolVersion != PilotRuntimeProtocol.version) { throw PilotRuntimeInitializationException( failure: PilotRuntimeInitializationFailure.protocolVersionMismatch, - message: - 'pilot_runtime protocol version ' + message: 'pilot_runtime protocol version ' '${handshake.protocolVersion} is incompatible with client ' 'version ${PilotRuntimeProtocol.version}.', ); @@ -384,8 +383,7 @@ class PilotRuntimeClient { if (missingCapabilities.isNotEmpty) { throw PilotRuntimeInitializationException( failure: PilotRuntimeInitializationFailure.missingCapability, - message: - 'pilot_runtime handshake is missing required capabilities: ' + message: 'pilot_runtime handshake is missing required capabilities: ' '${missingCapabilities.join(', ')}.', ); } @@ -422,8 +420,7 @@ class PilotRuntimeClient { throw PilotRuntimeWidgetTreeCaptureException( failure: PilotRuntimeWidgetTreeCaptureFailure.setPubRootDirectoriesFailed, - message: - 'Flutter Inspector could not set pub root directories for ' + message: 'Flutter Inspector could not set pub root directories for ' 'Widget Tree capture: $error', cause: error, ); @@ -438,8 +435,7 @@ class PilotRuntimeClient { } catch (error) { throw PilotRuntimeWidgetTreeCaptureException( failure: PilotRuntimeWidgetTreeCaptureFailure.getRootWidgetTreeFailed, - message: - 'Flutter Inspector could not return the root summary ' + message: 'Flutter Inspector could not return the root summary ' 'Widget Tree: $error', cause: error, ); @@ -450,8 +446,7 @@ class PilotRuntimeClient { } on FormatException catch (error) { throw PilotRuntimeWidgetTreeCaptureException( failure: PilotRuntimeWidgetTreeCaptureFailure.invalidResponse, - message: - 'Flutter Inspector returned an invalid Widget Tree: ' + message: 'Flutter Inspector returned an invalid Widget Tree: ' '${error.message}', cause: error, ); @@ -624,8 +619,7 @@ class PilotRuntimeClient { } on PilotRuntimeServiceExtensionMissingException catch (error) { throw PilotRuntimeInitializationException( failure: PilotRuntimeInitializationFailure.missingHook, - message: - 'PilotRuntimeBinding.ensureInitialized() is not registered ' + message: 'PilotRuntimeBinding.ensureInitialized() is not registered ' 'on the debug Runtime Target.', cause: error, ); diff --git a/packages/pilot_runtime/lib/src/scroll_performer.dart b/packages/pilot_runtime/lib/src/scroll_performer.dart index 0e14973..aacb746 100644 --- a/packages/pilot_runtime/lib/src/scroll_performer.dart +++ b/packages/pilot_runtime/lib/src/scroll_performer.dart @@ -28,15 +28,13 @@ class PilotRuntimeScrollPerformer { required double deltaX, required double deltaY, }) async { - final Element? scrollableElement = handle == null - ? _primaryScrollable() - : _scrollableForHandle(handle); + 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 ' + message: 'Primary scrollable is missing or ambiguous for ' 'untargeted scroll.', ) : _failure( diff --git a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart index fbc4fa4..0cfc031 100644 --- a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart @@ -16,9 +16,9 @@ void main() { debugMode: true, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - registeredExtensions.add(extensionName); - registeredHandlers[extensionName] = handler; - }, + registeredExtensions.add(extensionName); + registeredHandlers[extensionName] = handler; + }, ); expect(registeredExtensions, [ @@ -54,8 +54,8 @@ void main() { debugMode: false, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - registeredExtensions.add(extensionName); - }, + registeredExtensions.add(extensionName); + }, ); expect(registeredExtensions, isEmpty); @@ -70,8 +70,8 @@ void main() { debugMode: true, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - registeredHandlers[extensionName] = handler; - }, + registeredHandlers[extensionName] = handler; + }, ); await tester.pumpWidget( MaterialApp( @@ -87,8 +87,8 @@ void main() { final Map response = await registeredHandlers[PilotRuntimeProtocol.scrollExtension]!( - const {'deltaX': '0', 'deltaY': '-120.5'}, - ); + const {'deltaX': '0', 'deltaY': '-120.5'}, + ); expect(response['ok'], isTrue); expect(response['method'], 'pointer'); diff --git a/packages/pilot_runtime/test/pilot_runtime_client_test.dart b/packages/pilot_runtime/test/pilot_runtime_client_test.dart index 8f7f735..eadec8f 100644 --- a/packages/pilot_runtime/test/pilot_runtime_client_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_client_test.dart @@ -328,7 +328,7 @@ class FakePilotRuntimeVmService implements PilotRuntimeVmService { this.missingExtension = false, Map>? extensionResponses, }) : extensionResponses = - extensionResponses ?? const >{}; + extensionResponses ?? const >{}; final Map handshakeResponse; final bool missingExtension; diff --git a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index 9f9600f..f7e391e 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -1019,8 +1019,8 @@ Map _registerRuntimeExtensions() { debugMode: true, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - extensions[extensionName] = handler; - }, + extensions[extensionName] = handler; + }, ); return extensions; } From 9a2faf8378f4b3afefbc6e2cb2cb0d8b3b65a0fc Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 11:28:10 +0800 Subject: [PATCH 15/15] no-mistakes: apply CI fixes --- .../lib/src/pilot_runtime_binding.dart | 8 +- .../lib/src/pilot_runtime_client.dart | 18 +- .../lib/src/scroll_performer.dart | 8 +- packages/pilot_runtime/pubspec.lock | 189 ++++++++++++++++++ .../test/pilot_runtime_binding_test.dart | 18 +- .../test/pilot_runtime_client_test.dart | 2 +- .../test/pilot_runtime_finder_test.dart | 4 +- 7 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 packages/pilot_runtime/pubspec.lock diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart index 49b709b..63703d3 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_binding.dart @@ -15,16 +15,16 @@ import 'text_performer.dart'; /// The first runtime slice only needs a zero-argument handshake request. Later /// capabilities can add their own typed request handlers without changing the /// handshake registration contract. -typedef PilotRuntimeExtensionHandler = Future> Function( - Map parameters); +typedef PilotRuntimeExtensionHandler = + Future> Function(Map parameters); /// Registers one Flutter Pilot service extension with the debug VM Service. /// /// Tests can provide a fake registrar to inspect the extension name and invoke /// the handler without launching a Flutter app. Production callers omit the /// registrar so the binding registers through `dart:developer`. -typedef PilotRuntimeExtensionRegistrar = void Function( - String extensionName, PilotRuntimeExtensionHandler handler); +typedef PilotRuntimeExtensionRegistrar = + void Function(String extensionName, PilotRuntimeExtensionHandler handler); /// App-side hook that exposes Flutter Pilot runtime service extensions. /// diff --git a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart index 2304e48..e187189 100644 --- a/packages/pilot_runtime/lib/src/pilot_runtime_client.dart +++ b/packages/pilot_runtime/lib/src/pilot_runtime_client.dart @@ -371,7 +371,8 @@ class PilotRuntimeClient { if (handshake.protocolVersion != PilotRuntimeProtocol.version) { throw PilotRuntimeInitializationException( failure: PilotRuntimeInitializationFailure.protocolVersionMismatch, - message: 'pilot_runtime protocol version ' + message: + 'pilot_runtime protocol version ' '${handshake.protocolVersion} is incompatible with client ' 'version ${PilotRuntimeProtocol.version}.', ); @@ -383,7 +384,8 @@ class PilotRuntimeClient { if (missingCapabilities.isNotEmpty) { throw PilotRuntimeInitializationException( failure: PilotRuntimeInitializationFailure.missingCapability, - message: 'pilot_runtime handshake is missing required capabilities: ' + message: + 'pilot_runtime handshake is missing required capabilities: ' '${missingCapabilities.join(', ')}.', ); } @@ -420,7 +422,8 @@ class PilotRuntimeClient { throw PilotRuntimeWidgetTreeCaptureException( failure: PilotRuntimeWidgetTreeCaptureFailure.setPubRootDirectoriesFailed, - message: 'Flutter Inspector could not set pub root directories for ' + message: + 'Flutter Inspector could not set pub root directories for ' 'Widget Tree capture: $error', cause: error, ); @@ -435,7 +438,8 @@ class PilotRuntimeClient { } catch (error) { throw PilotRuntimeWidgetTreeCaptureException( failure: PilotRuntimeWidgetTreeCaptureFailure.getRootWidgetTreeFailed, - message: 'Flutter Inspector could not return the root summary ' + message: + 'Flutter Inspector could not return the root summary ' 'Widget Tree: $error', cause: error, ); @@ -446,7 +450,8 @@ class PilotRuntimeClient { } on FormatException catch (error) { throw PilotRuntimeWidgetTreeCaptureException( failure: PilotRuntimeWidgetTreeCaptureFailure.invalidResponse, - message: 'Flutter Inspector returned an invalid Widget Tree: ' + message: + 'Flutter Inspector returned an invalid Widget Tree: ' '${error.message}', cause: error, ); @@ -619,7 +624,8 @@ class PilotRuntimeClient { } on PilotRuntimeServiceExtensionMissingException catch (error) { throw PilotRuntimeInitializationException( failure: PilotRuntimeInitializationFailure.missingHook, - message: 'PilotRuntimeBinding.ensureInitialized() is not registered ' + message: + 'PilotRuntimeBinding.ensureInitialized() is not registered ' 'on the debug Runtime Target.', cause: error, ); diff --git a/packages/pilot_runtime/lib/src/scroll_performer.dart b/packages/pilot_runtime/lib/src/scroll_performer.dart index aacb746..0e14973 100644 --- a/packages/pilot_runtime/lib/src/scroll_performer.dart +++ b/packages/pilot_runtime/lib/src/scroll_performer.dart @@ -28,13 +28,15 @@ class PilotRuntimeScrollPerformer { required double deltaX, required double deltaY, }) async { - final Element? scrollableElement = - handle == null ? _primaryScrollable() : _scrollableForHandle(handle); + 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 ' + message: + 'Primary scrollable is missing or ambiguous for ' 'untargeted scroll.', ) : _failure( diff --git a/packages/pilot_runtime/pubspec.lock b/packages/pilot_runtime/pubspec.lock new file mode 100644 index 0000000..c45a5a1 --- /dev/null +++ b/packages/pilot_runtime/pubspec.lock @@ -0,0 +1,189 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.10.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart index 0cfc031..fbc4fa4 100644 --- a/packages/pilot_runtime/test/pilot_runtime_binding_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_binding_test.dart @@ -16,9 +16,9 @@ void main() { debugMode: true, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - registeredExtensions.add(extensionName); - registeredHandlers[extensionName] = handler; - }, + registeredExtensions.add(extensionName); + registeredHandlers[extensionName] = handler; + }, ); expect(registeredExtensions, [ @@ -54,8 +54,8 @@ void main() { debugMode: false, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - registeredExtensions.add(extensionName); - }, + registeredExtensions.add(extensionName); + }, ); expect(registeredExtensions, isEmpty); @@ -70,8 +70,8 @@ void main() { debugMode: true, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - registeredHandlers[extensionName] = handler; - }, + registeredHandlers[extensionName] = handler; + }, ); await tester.pumpWidget( MaterialApp( @@ -87,8 +87,8 @@ void main() { final Map response = await registeredHandlers[PilotRuntimeProtocol.scrollExtension]!( - const {'deltaX': '0', 'deltaY': '-120.5'}, - ); + const {'deltaX': '0', 'deltaY': '-120.5'}, + ); expect(response['ok'], isTrue); expect(response['method'], 'pointer'); diff --git a/packages/pilot_runtime/test/pilot_runtime_client_test.dart b/packages/pilot_runtime/test/pilot_runtime_client_test.dart index eadec8f..8f7f735 100644 --- a/packages/pilot_runtime/test/pilot_runtime_client_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_client_test.dart @@ -328,7 +328,7 @@ class FakePilotRuntimeVmService implements PilotRuntimeVmService { this.missingExtension = false, Map>? extensionResponses, }) : extensionResponses = - extensionResponses ?? const >{}; + extensionResponses ?? const >{}; final Map handshakeResponse; final bool missingExtension; diff --git a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart index f7e391e..9f9600f 100644 --- a/packages/pilot_runtime/test/pilot_runtime_finder_test.dart +++ b/packages/pilot_runtime/test/pilot_runtime_finder_test.dart @@ -1019,8 +1019,8 @@ Map _registerRuntimeExtensions() { debugMode: true, registerExtension: (String extensionName, PilotRuntimeExtensionHandler handler) { - extensions[extensionName] = handler; - }, + extensions[extensionName] = handler; + }, ); return extensions; }