From d86164778c559828c40b547f12a6a06ca1b7ebed Mon Sep 17 00:00:00 2001 From: Bartek Pacia Date: Wed, 6 Jul 2022 12:58:00 +0200 Subject: [PATCH] Different QOL improvements (#92) * maestrO_test: improve docs * AutomatorServer: add doubleTap() * maestro_test: add double tap * maestro_test: add MaestroTester.dragUntilVisible * rearrange code * make `$` accept `Key` * maestro_test: automatically call `pumpAndSettle()` after `dragUntilVisible()` * MaestroTester.dragUntilVisible: select the first finder by default * add `sleep` parameter for `maestroTest()` * make forwarded methods of `MaestroTester` accept less arguments * break `custom_selectors` into few files * add more doc comments * add more docs * maestroTest: don't sleep by default * add additional test cases for find.byKey * add Key as supported type to ArgumentError --- .../automatorserver/ServerInstrumentation.kt | 5 + .../UIAutomatorInstrumentation.kt | 16 +- .../src/features/doctor/doctor_command.dart | 3 +- .../src/features/update/update_command.dart | 3 +- .../lib/src/custom_selectors/common.dart | 147 +++++++++ .../custom_selectors/custom_selectors.dart | 312 +----------------- .../src/custom_selectors/maestro_finder.dart | 145 ++++++++ .../src/custom_selectors/maestro_tester.dart | 214 ++++++++++++ .../maestro_test/lib/src/native/maestro.dart | 7 + .../test/custom_selectors_test.dart | 28 +- 10 files changed, 564 insertions(+), 316 deletions(-) create mode 100644 packages/maestro_test/lib/src/custom_selectors/common.dart create mode 100644 packages/maestro_test/lib/src/custom_selectors/maestro_finder.dart create mode 100644 packages/maestro_test/lib/src/custom_selectors/maestro_tester.dart diff --git a/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/ServerInstrumentation.kt b/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/ServerInstrumentation.kt index ebf99568c..d6f9f95b5 100644 --- a/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/ServerInstrumentation.kt +++ b/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/ServerInstrumentation.kt @@ -294,6 +294,11 @@ class ServerInstrumentation { UIAutomatorInstrumentation.instance.tap(body) Response(OK) }, + "doubleTap" bind POST to { + val body = Json.decodeFromString(it.bodyString()) + UIAutomatorInstrumentation.instance.doubleTap(body) + Response(OK) + }, "enterTextByIndex" bind POST to { val body = Json.decodeFromString(it.bodyString()) UIAutomatorInstrumentation.instance.enterText(body.index, body.text) diff --git a/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/UIAutomatorInstrumentation.kt b/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/UIAutomatorInstrumentation.kt index 9436dcc90..3f86f27a5 100644 --- a/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/UIAutomatorInstrumentation.kt +++ b/AutomatorServer/app/src/androidTest/java/pl/leancode/automatorserver/UIAutomatorInstrumentation.kt @@ -156,7 +156,21 @@ class UIAutomatorInstrumentation { val uiObject = device.findObject(selector) - Logger.d("Clicking on UIObject ${uiObject.text}") + Logger.d("Clicking on UIObject with text: ${uiObject.text}") + uiObject.click() + } + + fun doubleTap(query: SelectorQuery) { + Logger.d("doubleTap()") + + val device = getUiDevice() + val selector = query.toUiSelector() + Logger.d("Selector: $selector") + + val uiObject = device.findObject(selector) + + Logger.d("Double clicking on UIObject with text: ${uiObject.text}") + uiObject.click() uiObject.click() } diff --git a/packages/maestro_cli/lib/src/features/doctor/doctor_command.dart b/packages/maestro_cli/lib/src/features/doctor/doctor_command.dart index 607f5dbf0..276c8593f 100644 --- a/packages/maestro_cli/lib/src/features/doctor/doctor_command.dart +++ b/packages/maestro_cli/lib/src/features/doctor/doctor_command.dart @@ -1,6 +1,5 @@ import 'package:args/command_runner.dart'; - -import '../../common/common.dart'; +import 'package:maestro_cli/src/common/common.dart'; class DoctorCommand extends Command { DoctorCommand() { diff --git a/packages/maestro_cli/lib/src/features/update/update_command.dart b/packages/maestro_cli/lib/src/features/update/update_command.dart index b1c530c29..d6bc42500 100644 --- a/packages/maestro_cli/lib/src/features/update/update_command.dart +++ b/packages/maestro_cli/lib/src/features/update/update_command.dart @@ -1,8 +1,7 @@ import 'package:args/command_runner.dart'; +import 'package:maestro_cli/src/common/common.dart'; import 'package:pub_updater/pub_updater.dart'; -import '../../common/common.dart'; - class UpdateCommand extends Command { UpdateCommand() : _pubUpdater = PubUpdater(); diff --git a/packages/maestro_test/lib/src/custom_selectors/common.dart b/packages/maestro_test/lib/src/custom_selectors/common.dart new file mode 100644 index 000000000..7e2684cc0 --- /dev/null +++ b/packages/maestro_test/lib/src/custom_selectors/common.dart @@ -0,0 +1,147 @@ +import 'dart:io' as io; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maestro_test/src/custom_selectors/maestro_finder.dart'; +import 'package:maestro_test/src/custom_selectors/maestro_tester.dart'; +import 'package:maestro_test/src/extensions.dart'; +import 'package:meta/meta.dart'; + +/// Signature for callback to [maestroTest]. +typedef MaestroTesterCallback = Future Function(MaestroTester $); + +/// Like [testWidgets], but with Maestro custom selector support. +/// +/// If you want to not close the app immediately after the test completes, use +/// [sleep]. +/// +/// ### Custom selectors +/// +/// Custom selectors greatly simplify writing widget tests. +/// +/// ### Using the default [WidgetTester] +/// If you need to do something using Flutter's [WidgetTester], you can access +/// it like this: +/// +/// ```dart +/// maestroTest( +/// 'increase counter text', +/// ($) async { +/// await $.tester.tap(find.byIcon(Icons.add)); +/// }, +/// ); +/// ``` +@isTest +void maestroTest( + String description, + MaestroTesterCallback callback, { + bool? skip, + Timeout? timeout, + bool semanticsEnabled = true, + TestVariant variant = const DefaultTestVariant(), + Duration sleep = Duration.zero, + dynamic tags, +}) { + return testWidgets( + description, + (widgetTester) async { + await callback(MaestroTester(widgetTester)); + io.sleep(sleep); + }, + skip: skip, + timeout: timeout, + semanticsEnabled: semanticsEnabled, + variant: variant, + tags: tags, + ); +} + +/// Creates a [Finder] from [matching]. +/// +/// ### Usage +/// +/// Usually, you won't use `createFinder` directly. Instead, you'll use +/// [MaestroTester.call] and [MaestroFinder.$], like this: +/// +/// ```dart +/// maestroTest( +/// 'increase counter text', +/// ($) async { +/// // calls createFinder method under the hood +/// await $(Scaffold).$(#passwordTextField).enterText('my password'); +/// }, +/// ); +/// ``` +/// +/// ### What does this method accept? +/// +/// The [Finder] that this method returns depends on the type of [matching]. +/// Supported types of [matching] are: +/// - [Type], which translates to [CommonFinders.byType], for example: +/// ```dart +/// final finder = createFinder(Button); +/// ``` +/// - [Key], which translates to [CommonFinders.byKey], for example: +/// ```dart +/// final finder = createFinder(Key('signInWithGoogle')); +/// ``` +/// - [Symbol], which translates to [CommonFinders.byKey], for example: +/// ```dart +/// final finder = createFinder(#signInWithGoogle); +/// ``` +/// - [String], which translates to [CommonFinders.text], for example: +/// ```dart +/// final finder = createFinder('Sign in with Google'); +/// ``` +/// - [Pattern], which translates to [CommonFinders.textContaining]. Example +/// [Pattern] is a [RegExp]. +/// ```dart +/// final finder = createFinder(RegExp('.*in with.*')); +/// ``` +/// - [IconData], which translates to [CommonFinders.byIcon], for example: +/// ```dart +/// final finder = createFinder(Icons.add); +/// ``` +/// - [MaestroFinder], which returns a [Finder] that the [MaestroFinder], for +/// example: passed as [matching] resolves to. +/// ```dart +/// final finder = createFinder($(Text('Sign in with Google'))); +/// ``` +/// +/// See also: +/// - [MaestroTester.call] +/// - [MaestroFinder.$] +/// - [MaestroFinder.resolve] +Finder createFinder(dynamic matching) { + if (matching is Type) { + return find.byType(matching); + } + + if (matching is Key) { + return find.byKey(matching); + } + + if (matching is Symbol) { + return find.byKey(Key(matching.name)); + } + + if (matching is String) { + return find.text(matching); + } + + if (matching is Pattern) { + return find.textContaining(matching); + } + + if (matching is IconData) { + return find.byIcon(matching); + } + + if (matching is MaestroFinder) { + return matching.finder; + } + + throw ArgumentError( + 'expression of type ${matching.runtimeType} is not one of supported types `Type`, `Key`, `Symbol`, `String`, `Pattern`, `IconData`, or `MaestroFinder`', + ); +} diff --git a/packages/maestro_test/lib/src/custom_selectors/custom_selectors.dart b/packages/maestro_test/lib/src/custom_selectors/custom_selectors.dart index 8c851fdbf..366d4ab17 100644 --- a/packages/maestro_test/lib/src/custom_selectors/custom_selectors.dart +++ b/packages/maestro_test/lib/src/custom_selectors/custom_selectors.dart @@ -1,309 +1,3 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:maestro_test/src/extensions.dart'; -import 'package:meta/meta.dart'; - -/// Signature for callback to [maestroTest]. -typedef MaestroTesterCallback = Future Function(MaestroTester $); - -/// Like [testWidgets], but with Maestro custom selector support. -/// -/// ### Using the default [WidgetTester] -/// If you need to do something using Flutter's [WidgetTester], you can access -/// it like this: -/// -/// ```dart -/// maestroTest( -/// 'increase counter text', -/// ($) async { -/// await $.tester.tap(find.byIcon(Icons.add)); -/// }, -/// ); -/// ``` -@isTest -void maestroTest( - String description, - MaestroTesterCallback callback, { - bool? skip, - Timeout? timeout, - bool semanticsEnabled = true, - TestVariant variant = const DefaultTestVariant(), - dynamic tags, -}) { - return testWidgets( - description, - (widgetTester) => callback(MaestroTester(widgetTester)), - skip: skip, - timeout: timeout, - semanticsEnabled: semanticsEnabled, - variant: variant, - tags: tags, - ); -} - -/// A decorator around [Finder] that provides Maestro _custom selector_ (also -/// known as `$`). -/// -/// -class MaestroFinder extends MatchFinder { - /// Creates a new [MaestroFinder] with the given [finder] and [tester]. - /// - /// Usually, you won't use this constructor directly. Instead, you'll use the - /// [MaestroTester] (which is provided by [MaestroTesterCallback] in - /// [maestroTest]) and [MaestroFinder.$]. - MaestroFinder({required this.finder, required this.tester}); - - /// Finder that this [MaestroFinder] wraps. - final Finder finder; - - /// Widget tester that this [MaestroFinder] wraps. - final WidgetTester tester; - - /// Taps on the widget resolved by this finder. - /// - /// If more than one widget is found, the [index]-th widget is tapped, instead - /// of throwing an exception (like [WidgetTester.tap] does). - /// - /// This method automatically calls [WidgetTester.pumpAndSettle] after tap. If - /// you want to disable this behavior, pass `false` to [andSettle]. - /// - /// See also: - /// - [WidgetController.tap] (which [WidgetTester] extends from) - Future tap({bool andSettle = true, int index = 0}) async { - await tester.tap(finder.at(index)); - - if (andSettle) { - await tester.pumpAndSettle(); - } else { - await tester.pump(); - } - } - - /// Enters text into the widget resolved by this finder. - /// - /// If more than one widget is found, [text] in entered into the [index]-th - /// widget, instead of throwing an exception (like [WidgetTester.enterText] - /// does). - /// - /// This method automatically calls [WidgetTester.pumpAndSettle] after - /// entering text. If you want to disable this behavior, pass `false` to - /// [andSettle]. - /// - /// See also: - /// - [WidgetTester.enterText] - Future enterText( - String text, { - bool andSettle = true, - int index = 0, - }) async { - await tester.enterText(finder.at(index), text); - - if (andSettle) { - await tester.pumpAndSettle(); - } else { - await tester.pump(); - } - } - - /// If this [MaestroFinder] matches a [Text] widget, then this method returns - /// its data. - /// - /// Otherwise it throws an error. - String? get text { - return (finder.evaluate().first.widget as Text).data; - } - - /// Returns a [MaestroFinder] that looks for [matching] in descendants of this - /// [MaestroFinder]. - MaestroFinder $(dynamic matching) { - return _$( - matching: matching, - tester: tester, - parentFinder: this, - ); - } - - /// Returns a [MaestroFinder] that this method was called on. - /// - /// Checks whether the [Widget] that this [MaestroFinder] was called on has - /// [matching] as a descendant. - MaestroFinder withDescendant(dynamic matching) { - return MaestroFinder( - tester: tester, - finder: find.ancestor( - of: _createFinder(matching), - matching: finder, - ), - ); - } - - @override - Iterable evaluate() { - return finder.evaluate(); - } - - @override - Iterable apply(Iterable candidates) { - return finder.apply(candidates); - } - - @override - String get description => finder.description; - - @override - bool matches(Element candidate) { - return (finder as MatchFinder).matches(candidate); - } -} - -/// A [MaestroFinder] wraps a [WidgetTester]. -/// -/// Usually, you won't create a [MaestroFinder] instance directly. Instead, -/// you'll use the [MaestroTester] which is provided by [MaestroTesterCallback] -/// in [maestroTest], like this: -/// -/// ```dart -/// import 'package:maestro_test/maestro_test.dart'; -/// -/// void main() { -/// maestroTest('Counter increments smoke test', (maestroTester) async { -/// await maestroTester.pumpWidgetAndSettle(const MyApp()); -/// await maestroTester(#startAppButton).tap(); -/// }); -/// } -/// ``` -/// -/// To make test code more concise, `maestroTester` variable is usually called -/// `$`, like this: -/// -/// ```dart -/// import 'package:maestro_test/maestro_test.dart'; -/// void main() { -/// maestroTest('Counter increments smoke test', ($) async { -/// await $.pumpWidgetAndSettle(const MyApp()); -/// await $(#startAppButton).tap(); -/// }); -/// } -/// ``` -/// You can call [MaestroTester] just like a normal method, because it is a -/// [callable class][callable-class]. -/// -/// [callable-class]: -/// https://dart.dev/guides/language/language-tour#callable-classes -class MaestroTester { - /// Creates a new [MaestroTester] with the given WidgetTester [tester]. - const MaestroTester(this.tester); - - /// Widget tester that this [MaestroTester] wraps. - final WidgetTester tester; - - /// Returns a [MaestroFinder] that matches [matching]. - /// - /// Refer to - MaestroFinder call(dynamic matching) { - return _$( - matching: matching, - tester: tester, - parentFinder: null, - ); - } - - /// See [WidgetTester.pumpWidget]. - Future pumpWidget( - Widget widget, [ - Duration? duration, - EnginePhase phase = EnginePhase.sendSemanticsUpdate, - ]) async { - await tester.pumpWidget(widget, duration, phase); - } - - /// See [WidgetTester.pumpAndSettle]. - Future pumpAndSettle([ - Duration duration = const Duration(milliseconds: 100), - EnginePhase phase = EnginePhase.sendSemanticsUpdate, - Duration timeout = const Duration(minutes: 10), - ]) async { - await tester.pumpAndSettle(); - } - - /// A convenience method combining [WidgetTester.pumpWidget] and - /// [WidgetTester.pumpAndSettle]. - Future pumpWidgetAndSettle( - Widget widget, [ - Duration? pumpWidgetDuration, - EnginePhase pumpWidgetPhase = EnginePhase.sendSemanticsUpdate, - Duration pumpAndSettleDuration = const Duration(milliseconds: 100), - Duration pumpAndSettleTimeout = const Duration(minutes: 10), - EnginePhase pumpAndSettlePhase = EnginePhase.sendSemanticsUpdate, - ]) async { - await tester.pumpWidget(widget, pumpWidgetDuration, pumpWidgetPhase); - await tester.pumpAndSettle( - pumpAndSettleDuration, - pumpAndSettlePhase, - pumpAndSettleTimeout, - ); - } -} - -/// Creates a [Finder] from [expression]. -/// -/// The [Finder] that this method returns depends on the type of [expression]. -/// Supported [expression] types are: -/// - [Type], which translates to [CommonFinders.byType] -/// - [Symbol], which translates to [CommonFinders.byKey] -/// - [String], which translates to [CommonFinders.text] -/// - [Pattern], which translates to [CommonFinders.textContaining]. Example -/// [Pattern] is a [RegExp]. -/// - [IconData], which translates to [CommonFinders.byIcon] -/// - [MaestroFinder], which returns a [Finder] that the [MaestroFinder] passed -/// as [expression] resolves to. -Finder _createFinder(dynamic expression) { - if (expression is Type) { - return find.byType(expression); - } - - if (expression is Symbol) { - return find.byKey(Key(expression.name)); - } - - if (expression is String) { - return find.text(expression); - } - - if (expression is Pattern) { - return find.textContaining(expression); - } - - if (expression is IconData) { - return find.byIcon(expression); - } - - if (expression is MaestroFinder) { - return expression.finder; - } - - throw ArgumentError( - 'expression must be of type `Type`, `Symbol`, `String`, `Pattern`, `IconData`, or `MaestroFinder`', - ); -} - -MaestroFinder _$({ - required dynamic matching, - required WidgetTester tester, - required Finder? parentFinder, -}) { - if (parentFinder != null) { - return MaestroFinder( - tester: tester, - finder: find.descendant( - of: parentFinder, - matching: _createFinder(matching), - ), - ); - } - - return MaestroFinder( - tester: tester, - finder: _createFinder(matching), - ); -} +export 'common.dart' hide createFinder; +export 'maestro_finder.dart'; +export 'maestro_tester.dart'; diff --git a/packages/maestro_test/lib/src/custom_selectors/maestro_finder.dart b/packages/maestro_test/lib/src/custom_selectors/maestro_finder.dart new file mode 100644 index 000000000..dfaa7d6ef --- /dev/null +++ b/packages/maestro_test/lib/src/custom_selectors/maestro_finder.dart @@ -0,0 +1,145 @@ +library custom_selectors; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maestro_test/src/custom_selectors/common.dart'; + +import 'maestro_tester.dart'; + +/// A decorator around [Finder] that provides Maestro _custom selector_ (also +/// known as `$`). +class MaestroFinder extends MatchFinder { + /// Creates a new [MaestroFinder] with the given [finder] and [tester]. + /// + /// Usually, you won't use this constructor directly. Instead, you'll use the + /// [MaestroTester] (which is provided by [MaestroTesterCallback] in + /// [maestroTest]) and [MaestroFinder.$]. + MaestroFinder({required this.finder, required this.tester}); + + /// Returns a [MaestroFinder] that looks for [matching] in descendants of + /// [parentFinder]. If [parentFinder] is null, it looks for [matching] + /// anywhere in the widget tree. + factory MaestroFinder.resolve({ + required dynamic matching, + required Finder? parentFinder, + required WidgetTester tester, + }) { + final finder = createFinder(matching); + + if (parentFinder != null) { + return MaestroFinder( + tester: tester, + finder: find.descendant( + of: parentFinder, + matching: finder, + ), + ); + } + + return MaestroFinder( + tester: tester, + finder: finder, + ); + } + + /// Finder that this [MaestroFinder] wraps. + final Finder finder; + + /// Widget tester that this [MaestroFinder] wraps. + final WidgetTester tester; + + /// Taps on the widget resolved by this finder. + /// + /// If more than one widget is found, the [index]-th widget is tapped, instead + /// of throwing an exception (like [WidgetTester.tap] does). + /// + /// This method automatically calls [WidgetTester.pumpAndSettle] after tap. If + /// you want to disable this behavior, pass `false` to [andSettle]. + /// + /// See also: + /// - [WidgetController.tap] (which [WidgetTester] extends from) + Future tap({bool andSettle = true, int index = 0}) async { + await tester.tap(finder.at(index)); + + if (andSettle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } + } + + /// Enters text into the widget resolved by this finder. + /// + /// If more than one widget is found, [text] in entered into the [index]-th + /// widget, instead of throwing an exception (like [WidgetTester.enterText] + /// does). + /// + /// This method automatically calls [WidgetTester.pumpAndSettle] after + /// entering text. If you want to disable this behavior, pass `false` to + /// [andSettle]. + /// + /// See also: + /// - [WidgetTester.enterText] + Future enterText( + String text, { + bool andSettle = true, + int index = 0, + }) async { + await tester.enterText(finder.at(index), text); + + if (andSettle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } + } + + /// If this [MaestroFinder] matches a [Text] widget, then this method returns + /// its data. + /// + /// Otherwise it throws an error. + String? get text { + return (finder.evaluate().first.widget as Text).data; + } + + /// A shortcut for [MaestroFinder.resolve] + MaestroFinder $(dynamic matching) { + return MaestroFinder.resolve( + matching: matching, + tester: tester, + parentFinder: this, + ); + } + + /// Returns a [MaestroFinder] that this method was called on. + /// + /// Checks whether the [Widget] that this [MaestroFinder] was called on has + /// [matching] as a descendant. + MaestroFinder withDescendant(dynamic matching) { + return MaestroFinder( + tester: tester, + finder: find.ancestor( + of: createFinder(matching), + matching: finder, + ), + ); + } + + @override + Iterable evaluate() { + return finder.evaluate(); + } + + @override + Iterable apply(Iterable candidates) { + return finder.apply(candidates); + } + + @override + String get description => finder.description; + + @override + bool matches(Element candidate) { + return (finder as MatchFinder).matches(candidate); + } +} diff --git a/packages/maestro_test/lib/src/custom_selectors/maestro_tester.dart b/packages/maestro_test/lib/src/custom_selectors/maestro_tester.dart new file mode 100644 index 000000000..5fd050caf --- /dev/null +++ b/packages/maestro_test/lib/src/custom_selectors/maestro_tester.dart @@ -0,0 +1,214 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maestro_test/src/custom_selectors/common.dart'; +import 'package:maestro_test/src/custom_selectors/maestro_finder.dart'; + +/// Default amount of space to scroll by in a vertical [Scrollable] +const verticalStep = Offset(0, 16); + +/// Default amount of space to scroll by in a horizontal [Scrollable]. +const horizontalStep = Offset(16, 0); + +/// [MaestroTester] wraps a [WidgetTester]. It provides +/// - support for _Maestro custom selector_, a.k.a `$` +/// - convenience method for pumping widgets, scrolling, etc. +/// +/// If you want to do something that [WidgetTester] supports, but +/// [MaestroTester] does not, you can access the underlying [WidgetTester] via +/// [tester] field of [MaestroTester]. +/// +/// Usually, you won't create a [MaestroTester] instance directly. Instead, +/// you'll use the [MaestroTester] which is provided by [MaestroTesterCallback] +/// in [maestroTest], like this: +/// +/// ```dart +/// import 'package:maestro_test/maestro_test.dart'; +/// +/// void main() { +/// maestroTest('Counter increments smoke test', (maestroTester) async { +/// await maestroTester.pumpWidgetAndSettle(const MyApp()); +/// await maestroTester(#startAppButton).tap(); +/// }); +/// } +/// ``` +/// +/// To make test code more concise, `maestroTester` variable is usually called +/// `$`, like this: +/// +/// ```dart +/// import 'package:maestro_test/maestro_test.dart'; +/// void main() { +/// maestroTest('Counter increments smoke test', ($) async { +/// await $.pumpWidgetAndSettle(const MyApp()); +/// await $(#startAppButton).tap(); +/// }); +/// } +/// ``` +/// +/// +/// You can call [MaestroTester] just like a normal method, because it is a +/// [callable class][callable-class]. +/// +/// [callable-class]: +/// https://dart.dev/guides/language/language-tour#callable-classes +class MaestroTester { + /// Creates a new [MaestroTester] with the given WidgetTester [tester]. + const MaestroTester(this.tester); + + /// Widget tester that this [MaestroTester] wraps. + final WidgetTester tester; + + /// Returns a [MaestroFinder] that matches [matching]. + /// + /// See also: + /// - [MaestroFinder.resolve] + MaestroFinder call(dynamic matching) { + return MaestroFinder.resolve( + matching: matching, + tester: tester, + parentFinder: null, + ); + } + + /// See [WidgetTester.pumpWidget]. + Future pumpWidget( + Widget widget, [ + Duration? duration, + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + ]) async { + await tester.pumpWidget(widget, duration, phase); + } + + /// See [WidgetTester.pumpAndSettle]. + Future pumpAndSettle([ + Duration duration = const Duration(milliseconds: 100), + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + Duration timeout = const Duration(minutes: 10), + ]) async { + await tester.pumpAndSettle(); + } + + /// A convenience method combining [WidgetTester.pumpWidget] and + /// [WidgetTester.pumpAndSettle]. + /// + /// This method automatically calls [WidgetTester.pumpAndSettle] after tap. If + /// you want to disable this behavior, pass `false` to [andSettle]. + Future pumpWidgetAndSettle( + Widget widget, { + Duration? duration, + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + bool andSettle = true, + }) async { + await tester.pumpWidget(widget, duration, phase); + + if (andSettle) { + await tester.pumpAndSettle(); + } + } + + /// Convenience method combining `WidgetTester.drag` and + /// [WidgetTester.pumpAndSettle]. + /// + /// Specify [index] to select on which [finder] to tap. It defaults to the + /// first finder. + /// + /// This method automatically calls [WidgetTester.pumpAndSettle] after drag. + /// If you want to disable this behavior, pass `false` to [andSettle]. + /// + /// See also: + /// - [WidgetController.drag] + Future drag( + Finder finder, + Offset offset, { + int? pointer, + int buttons = kPrimaryButton, + double touchSlopX = kDragSlopDefault, + double touchSlopY = kDragSlopDefault, + bool warnIfMissed = true, + PointerDeviceKind kind = PointerDeviceKind.touch, + int index = 0, + bool andSettle = true, + }) async { + await tester.drag( + finder.at(index), + offset, + pointer: pointer, + buttons: buttons, + touchSlopX: touchSlopX, + touchSlopY: touchSlopY, + kind: kind, + ); + + if (andSettle) { + await tester.pumpAndSettle(); + } + } + + /// Convenience method combining `WidgetTester.dragFrom` and + /// [WidgetTester.pumpAndSettle]. + /// + /// This method automatically calls [WidgetTester.pumpAndSettle] after tap. If + /// you want to disable this behavior, pass `false` to [andSettle]. + /// + /// See also: + /// - [WidgetController.dragFrom]. + Future dragFrom( + Offset startLocation, + Offset offset, { + int? pointer, + int buttons = kPrimaryButton, + double touchSlopX = kDragSlopDefault, + double touchSlopY = kDragSlopDefault, + PointerDeviceKind kind = PointerDeviceKind.touch, + bool andSettle = true, + int index = 0, + }) async { + await tester.dragFrom( + startLocation, + offset, + pointer: pointer, + buttons: buttons, + touchSlopX: touchSlopX, + touchSlopY: touchSlopY, + kind: kind, + ); + + if (andSettle) { + await tester.pumpAndSettle(); + } + } + + /// Convenience method combining `WidgetTester.dragUntilVisible` and + /// [WidgetTester.pumpAndSettle]. + /// + /// Specify [index] to select on which [finder] to tap. It defaults to the + /// first finder. + /// + /// This method automatically calls [WidgetTester.pumpAndSettle] after tap. If + /// you want to disable this behavior, pass `false` to [andSettle]. + /// + /// See also: + /// - [WidgetController.dragUntilVisible]. + Future dragUntilVisible( + Finder finder, + Finder view, + Offset moveStep, { + int maxIteration = 50, + Duration duration = const Duration(milliseconds: 50), + bool andSettle = true, + int index = 0, + }) async { + await tester.dragUntilVisible( + finder.at(index), + view, + moveStep, + maxIteration: maxIteration, + duration: duration, + ); + + if (andSettle) { + await tester.pumpAndSettle(); + } + } +} diff --git a/packages/maestro_test/lib/src/native/maestro.dart b/packages/maestro_test/lib/src/native/maestro.dart index 68bce1abe..0fe3d73c3 100644 --- a/packages/maestro_test/lib/src/native/maestro.dart +++ b/packages/maestro_test/lib/src/native/maestro.dart @@ -250,6 +250,13 @@ class Maestro { return _wrapPost('tap', selector.toJson()); } + /// Double taps on the native widget specified by [selector]. + /// + /// If the native widget is not found, an exception is thrown. + Future doubleTap(Selector selector) { + return _wrapPost('doubleTap', selector.toJson()); + } + /// Enters text to the native widget specified by [selector]. /// /// The native widget specified by selector must be an EditText on Android. diff --git a/packages/maestro_test/test/custom_selectors_test.dart b/packages/maestro_test/test/custom_selectors_test.dart index de2eac2c0..d46887112 100644 --- a/packages/maestro_test/test/custom_selectors_test.dart +++ b/packages/maestro_test/test/custom_selectors_test.dart @@ -15,11 +15,25 @@ void main() { maestroTest('key', ($) async { await $.pumpWidgetAndSettle( - const MaterialApp( - home: Text('Hello', key: Key('hello')), + MaterialApp( + home: Column( + children: const [ + Text('Hello', key: Key('hello')), + Text('Some text', key: Key('Some \n long, complex\t\ttext!')), + Text('Another text', key: ValueKey({'key': 'value'})), + ], + ), ), ); expect($(#hello), findsOneWidget); + expect($(const Symbol('hello')), findsOneWidget); + expect($(const Key('hello')), findsOneWidget); + + expect($(const Symbol('Some \n long, complex\t\ttext!')), findsOneWidget); + expect($(const Key('Some \n long, complex\t\ttext!')), findsOneWidget); + + expect($(const ValueKey({'key': 'value'})), findsOneWidget); + expect($(const ValueKey({'key': 'value1'})), findsNothing); }); maestroTest('text', ($) async { @@ -109,6 +123,16 @@ void main() { expect($(SizedBox).withDescendant(Text), findsOneWidget); expect($(Column).withDescendant('Hello 2'), findsOneWidget); + + final columnFinder = $(Column).withDescendant( + $(Container).withDescendant('Hello 1'), + ); + expect(columnFinder, findsOneWidget); + expect(columnFinder.finder.evaluate().first.widget.runtimeType, Column); + + final column2Finder = + $(Column).withDescendant(Container).withDescendant(#helloText); + expect(column2Finder, findsNWidgets(2)); }); }); }