From ec13aa362f8ac2b0fcb8b9610c26c8662d9c8cfc Mon Sep 17 00:00:00 2001 From: Patrick Bertsch Date: Thu, 8 Oct 2026 11:49:31 -0600 Subject: [PATCH] fix: bare scroll skips non-scrollable scrollables; iOS dialog tap verified; release 0.19.3 (FP-35) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0122FvtGQmjNf8YsduMLvZLh --- CHANGELOG.md | 13 ++++++++ VERSION | 2 +- docs/wiki/Home.md | 2 +- ios-driver/UITests/Driver.swift | 27 ++++++++++++++++- probe_agent/CHANGELOG.md | 4 +++ probe_agent/lib/src/agent_version.dart | 2 +- probe_agent/lib/src/executor.dart | 32 ++++++++++++-------- probe_agent/pubspec.yaml | 2 +- probe_agent/test/scroll_target_test.dart | 38 ++++++++++++++++++++++++ vscode/package.json | 2 +- 10 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 probe_agent/test/scroll_target_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 86023273..7fe48cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.19.3] - 2026-10-08 + +### Fixed +- **A bare `scroll down` / `swipe` picks a scrollable that can scroll.** It chose the largest scrollable under the touch point, which + could be a `PageView` with `NeverScrollableScrollPhysics` (the biggest scrollable on its screen) and then moved nothing, while + the page inside it needed the scroll (`scroll down until "..." appears` reported "not found after scrolling" on an onboarding + flow). Scrollables with a scrollable extent that accept user input now rank first, then the one that receives touches, then + the largest. +- **iOS `probe system-dialog tap` no longer reports a tap that did nothing as done:** after tapping, the driver checks that the + dialog went away (it polls for about a second), retries once with a tap on the button's centre point, and otherwise + fails with `tapped "Open" but the dialog is still showing` (seen with SpringBoard's "Open in ?" confirmation). + Not verified against that dialog here; the driver builds and the other dialog paths are unchanged. + ## [0.19.2] - 2026-10-08 ### Fixed diff --git a/VERSION b/VERSION index 61e6e92d..b72b05ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.2 +0.19.3 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a5348e87..a7541d5a 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -18,7 +18,7 @@ Welcome to the FlutterProbe wiki. This documentation covers architecture details ## Project Status -FlutterProbe is in active development. Current version: **0.19.2**. +FlutterProbe is in active development. Current version: **0.19.3**. ### Repository Structure diff --git a/ios-driver/UITests/Driver.swift b/ios-driver/UITests/Driver.swift index 14399d44..9c88c2b8 100644 --- a/ios-driver/UITests/Driver.swift +++ b/ios-driver/UITests/Driver.swift @@ -6,7 +6,7 @@ import XCTest /// /// Secrets: `type` never echoes the text back in any response or log line. final class Driver { - static let version = "0.19.2" + static let version = "0.19.3" /// Processes that can present system UI. Only ones that are running are /// queried (asking an app that is not running for its UI would launch it). @@ -193,10 +193,35 @@ final class Driver { return (200, ["ok": false, "error": "no button \"\(wanted)\" in the dialog", "buttons": d.buttonLabels, "title": d.title]) } let label = button.label + let signature = d.buttonLabels button.tap() + if stillShowing(title: title, signature: signature, appID: body["app"] as? String) { + // The tap was reported by XCUITest but the dialog did not go away (seen with + // SpringBoard's "Open in ?" confirmation). Tap the button's centre point + // instead of asking the element, then check again. + if let again = find(title: title, appID: body["app"] as? String), + let b = match(wanted, in: again.buttons, by: { $0.label }) { + b.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + if stillShowing(title: title, signature: signature, appID: body["app"] as? String) { + return (200, ["ok": false, "error": "tapped \"\(label)\" but the dialog is still showing", + "buttons": signature, "title": d.title]) + } + } return (200, ["ok": true, "tapped": label]) } + /// True when a dialog with the same buttons is still on screen after a tap (polled + /// for up to ~1.2 s, since dialogs animate away). + private func stillShowing(title: String?, signature: [String], appID: String?) -> Bool { + let deadline = Date(timeIntervalSinceNow: 1.2) + repeat { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.2)) + guard let d = find(title: title, appID: appID), d.buttonLabels == signature else { return false } + } while Date() < deadline + return true + } + func dismiss(_ body: [String: Any]) -> (Int, [String: Any]) { let title = body["title"] as? String guard let d = find(title: title, appID: body["app"] as? String) else { diff --git a/probe_agent/CHANGELOG.md b/probe_agent/CHANGELOG.md index 882ed2e7..c871e977 100644 --- a/probe_agent/CHANGELOG.md +++ b/probe_agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## 0.19.3 - 2026-10-08 + +- Fixed: the target of a bare `scroll` / `swipe` ranks scrollables that can scroll (extent > 0, physics accepting user input) before ones that cannot (a non-scrollable PageView). + ## 0.19.2 - 2026-10-08 - Fixed: tap, long press, double tap, type and clear scroll a built but off-screen target into view (since 0.19.0 they failed on it). diff --git a/probe_agent/lib/src/agent_version.dart b/probe_agent/lib/src/agent_version.dart index 9fe86af3..0f86e5e2 100644 --- a/probe_agent/lib/src/agent_version.dart +++ b/probe_agent/lib/src/agent_version.dart @@ -9,4 +9,4 @@ /// Lives in its own file (rather than server.dart, where it previously /// lived) so executor.dart can read it too without an executor.dart <-> /// server.dart import cycle. -const String probeAgentVersion = '0.19.2'; +const String probeAgentVersion = '0.19.3'; diff --git a/probe_agent/lib/src/executor.dart b/probe_agent/lib/src/executor.dart index 103a8f87..2994bcea 100644 --- a/probe_agent/lib/src/executor.dart +++ b/probe_agent/lib/src/executor.dart @@ -1259,10 +1259,13 @@ class ProbeExecutor { // order and the same size as the visible one, and `scroll down` then moves // nothing on screen. Prefer a scrollable that actually receives touches at // its own center; fall back to the largest of all if none does. - ScrollableState? bestHit; - double bestHitArea = 0; - ScrollableState? bestAny; - double bestAnyArea = 0; + // Rank: a scrollable that can actually be scrolled by the user (extent > 0 and physics that + // accepts user input) beats one that cannot — a PageView with NeverScrollableScrollPhysics is + // the biggest scrollable on its screen but never moves, while the page inside it does — then + // one that receives touches at its center, then the largest. + ScrollableState? best; + var bestRank = -1; + var bestArea = 0.0; void visit(Element e) { if (probeRouteOf(e)?.isCurrent == false) return; if (e is StatefulElement && e.state is ScrollableState) { @@ -1270,21 +1273,26 @@ class ProbeExecutor { final box = state.context.findRenderObject(); if (box is RenderBox && box.hasSize) { final area = box.size.width * box.size.height; - if (area > bestAnyArea) { - bestAnyArea = area; - bestAny = state; + var rank = 0; + try { + final pos = state.position; + final extent = pos.maxScrollExtent - pos.minScrollExtent; + if (extent > 1.0 && pos.physics.shouldAcceptUserOffset(pos)) rank += 2; + } catch (_) { + // position not ready: treat as scrollable-unknown (rank unchanged) } - if (area > bestHitArea && box.attached && - _isTopmostAt(box, box.localToGlobal(box.size.center(Offset.zero)))) { - bestHitArea = area; - bestHit = state; + if (box.attached && _isTopmostAt(box, box.localToGlobal(box.size.center(Offset.zero)))) rank += 1; + if (rank > bestRank || (rank == bestRank && area > bestArea)) { + bestRank = rank; + bestArea = area; + best = state; } } } e.visitChildren(visit); } WidgetsBinding.instance.rootElement?.visitChildren(visit); - return bestHit ?? bestAny; + return best; } Future _drag( diff --git a/probe_agent/pubspec.yaml b/probe_agent/pubspec.yaml index 80b33de2..b61f3604 100644 --- a/probe_agent/pubspec.yaml +++ b/probe_agent/pubspec.yaml @@ -3,7 +3,7 @@ description: >- On-device E2E test agent for FlutterProbe. Embeds in your Flutter app and executes test commands via direct widget-tree access with sub-50ms latency. -version: 0.19.2 +version: 0.19.3 homepage: https://flutterprobe.dev documentation: https://flutterprobe.dev/tools/agent/ repository: https://github.com/AlphaWaveSystems/flutter-probe diff --git a/probe_agent/test/scroll_target_test.dart b/probe_agent/test/scroll_target_test.dart new file mode 100644 index 00000000..650fe5b3 --- /dev/null +++ b/probe_agent/test/scroll_target_test.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_probe_agent/src/executor.dart'; +import 'package:flutter_probe_agent/src/protocol.dart'; + +void main() { + testWidgets('a bare scroll moves the page inside a non-scrollable PageView, not the PageView', (tester) async { + final inner = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: PageView( + physics: const NeverScrollableScrollPhysics(), + children: [ + SingleChildScrollView( + controller: inner, + child: Column(children: const [SizedBox(height: 2400, child: Text('Top')), Text('Bottom')]), + ), + ], + ), + ), + )); + + String? raw; + final ex = ProbeExecutor((s) => raw = s); + var done = false; + final call = ex + .dispatch(ProbeRequest(jsonrpc: '2.0', id: 1, method: ProbeMethods.scroll, params: {'direction': 'down'})) + .whenComplete(() => done = true); + for (var i = 0; i < 100 && !done; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await call; + expect(jsonDecode(raw!)['error'], isNull); + expect(inner.offset, greaterThan(0), reason: 'the scrollable page must have moved'); + }); +} diff --git a/vscode/package.json b/vscode/package.json index a285d41d..dc8ed619 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "name": "flutterprobe", "displayName": "FlutterProbe", "description": "High-performance E2E testing for Flutter apps — ProbeScript language support, local & cloud device testing (BrowserStack, Sauce Labs, AWS Device Farm, Firebase Test Lab, LambdaTest), visual regression, test recording, and Studio integration", - "version": "0.19.2", + "version": "0.19.3", "publisher": "flutterprobe", "icon": "resources/probe-icon.png", "engines": { "vscode": "^1.85.0" },