Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>?" confirmation).
Not verified against that dialog here; the driver builds and the other dialog paths are unchanged.

## [0.19.2] - 2026-10-08

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.19.2
0.19.3
2 changes: 1 addition & 1 deletion docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 26 additions & 1 deletion ios-driver/UITests/Driver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 <app>?" 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 {
Expand Down
4 changes: 4 additions & 0 deletions probe_agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion probe_agent/lib/src/agent_version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
32 changes: 20 additions & 12 deletions probe_agent/lib/src/executor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1259,32 +1259,40 @@ 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) {
final state = e.state as ScrollableState;
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<void> _drag(
Expand Down
2 changes: 1 addition & 1 deletion probe_agent/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions probe_agent/test/scroll_target_test.dart
Original file line number Diff line number Diff line change
@@ -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');
});
}
2 changes: 1 addition & 1 deletion vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading