diff --git a/.github/workflows/maestro_test-prepare.yaml b/.github/workflows/maestro_test-prepare.yaml index 5cd09987b..567d083af 100644 --- a/.github/workflows/maestro_test-prepare.yaml +++ b/.github/workflows/maestro_test-prepare.yaml @@ -57,6 +57,9 @@ jobs: - name: flutter analyze run: flutter analyze --no-fatal-infos + - name: flutter test + run: flutter test + - name: flutter pub get (example app) working-directory: ./packages/maestro_test/example run: flutter pub get diff --git a/.gitignore b/.gitignore index e43b0f988..4befed30a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +.idea diff --git a/packages/maestro_test/README.md b/packages/maestro_test/README.md index aef69d9a1..4809731e7 100644 --- a/packages/maestro_test/README.md +++ b/packages/maestro_test/README.md @@ -19,7 +19,7 @@ dev_dependencies: maestro_test: ^0.3.0 ``` -### Usage +### Using features of the underlying native platform ```dart // example/integration_test/example_test.dart @@ -63,16 +63,22 @@ import 'package:maestro_test/maestro_test.dart'; void main() { maestroTest( - 'counter state is the same after going to Home and switching apps', + 'logs in successfully', ($) async { await $.pumpWidgetAndSettle(const MyApp()); + await $(#emailInput).enterText('user@leancode.co'); + await $(#passwordInput).enterText('ny4ncat'); + // Find widget with text 'Log in' which is a descendant of widget with key // box1 which is a descendant of a Scaffold widget and tap on it. await $(Scaffold).$(#box1).$('Log in').tap(); // Selects the first Scrollable which has a Text descendant $(Scrollable).withDescendant(Text); + + // Selects the first Scrollable which has a Button descendant which has a Text descendant + $(Scrollable).withDescendant($(Text).withDescendant(Text)); }, ); } diff --git a/packages/maestro_test/analysis_options.yaml b/packages/maestro_test/analysis_options.yaml index 6c0a949cd..7572fb244 100644 --- a/packages/maestro_test/analysis_options.yaml +++ b/packages/maestro_test/analysis_options.yaml @@ -1,5 +1,6 @@ include: package:leancode_lint/analysis_options_package.yaml -linter: - rules: - public_member_api_docs: false +analyzer: + exclude: + - lib/**/*.freezed.dart + - lib/**/*.g.dart diff --git a/packages/maestro_test/lib/maestro_test.dart b/packages/maestro_test/lib/maestro_test.dart index dc8142be3..f7ae09011 100644 --- a/packages/maestro_test/lib/maestro_test.dart +++ b/packages/maestro_test/lib/maestro_test.dart @@ -2,8 +2,5 @@ /// flutter_driver. library maestro_test; -export 'src/custom_selectors.dart'; -export 'src/maestro.dart'; -export 'src/native_widget.dart'; -export 'src/notification.dart'; -export 'src/selector.dart'; +export 'src/custom_selectors/custom_selectors.dart'; +export 'src/native/native.dart'; diff --git a/packages/maestro_test/lib/src/custom_selectors.dart b/packages/maestro_test/lib/src/custom_selectors.dart deleted file mode 100644 index 818ab0975..000000000 --- a/packages/maestro_test/lib/src/custom_selectors.dart +++ /dev/null @@ -1,184 +0,0 @@ -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, - ); -} - -class MaestroFinder extends Finder { - MaestroFinder({required this.finder, required this.tester}); - - final Finder finder; - final WidgetTester tester; - - Future tap({bool andSettle = true, int index = 0}) async { - await tester.tap(finder.at(index)); - - if (andSettle) { - await tester.pumpAndSettle(); - } else { - await tester.pump(); - } - } - - 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; - } - - MaestroFinder $(dynamic matching) { - return _$( - matching: matching, - tester: tester, - parentFinder: finder, - ); - } - - MaestroFinder withDescendant(dynamic matching) { - return MaestroFinder( - tester: tester, - finder: find.ancestor( - of: _createFinder(matching), - matching: finder, - ), - ); - } - - @override - Iterable apply(Iterable candidates) { - return finder.apply(candidates); - } - - @override - String get description => finder.description; -} - -class MaestroTester { - MaestroTester(this.tester); - - final WidgetTester tester; - - 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, - ); - } - - MaestroFinder call(dynamic matching) { - return _$( - matching: matching, - tester: tester, - parentFinder: null, - ); - } -} - -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); - } - - throw ArgumentError( - 'expression must be of type `Type`, `Symbol`, `String`, `Pattern`, or `IconData`', - ); -} - -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), - ); -} diff --git a/packages/maestro_test/lib/src/custom_selectors/custom_selectors.dart b/packages/maestro_test/lib/src/custom_selectors/custom_selectors.dart new file mode 100644 index 000000000..8c851fdbf --- /dev/null +++ b/packages/maestro_test/lib/src/custom_selectors/custom_selectors.dart @@ -0,0 +1,309 @@ +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), + ); +} diff --git a/packages/maestro_test/lib/src/extensions.dart b/packages/maestro_test/lib/src/extensions.dart index d52925a6b..7b1b19275 100644 --- a/packages/maestro_test/lib/src/extensions.dart +++ b/packages/maestro_test/lib/src/extensions.dart @@ -1,12 +1,19 @@ import 'package:http/http.dart' as http; -extension IsOk on http.Response { +/// Provides a method to easily check the meaning of the HTTP status code. +extension IsResponseSuccessul on http.Response { + /// Returns true if the status code is 2xx, false otherwise. bool get successful { return (statusCode ~/ 100) == 2; } } -extension SymbolX on Symbol { +/// Makes it possible to retrieve a name that this [Symbol] was created with. +extension SymbolName on Symbol { + /// Returns the name that this [Symbol] was created with. + /// + /// It's kinda hacky, but works well. Might require adjustements to work on + /// the web though. String get name { final symbol = toString(); return symbol.substring(8, symbol.length - 2); diff --git a/packages/maestro_test/lib/src/maestro.dart b/packages/maestro_test/lib/src/native/maestro.dart similarity index 98% rename from packages/maestro_test/lib/src/maestro.dart rename to packages/maestro_test/lib/src/native/maestro.dart index be6f81f55..68bce1abe 100644 --- a/packages/maestro_test/lib/src/maestro.dart +++ b/packages/maestro_test/lib/src/native/maestro.dart @@ -4,8 +4,10 @@ import 'package:http/http.dart' as http; import 'package:integration_test/integration_test.dart'; import 'package:logging/logging.dart' as logging; import 'package:logging/logging.dart'; -import 'package:maestro_test/maestro_test.dart'; import 'package:maestro_test/src/extensions.dart'; +import 'package:maestro_test/src/native/native_widget.dart'; +import 'package:maestro_test/src/native/notification.dart'; +import 'package:maestro_test/src/native/selector.dart'; /// Provides functionality to control the device. /// diff --git a/packages/maestro_test/lib/src/native/native.dart b/packages/maestro_test/lib/src/native/native.dart new file mode 100644 index 000000000..3ee6396a8 --- /dev/null +++ b/packages/maestro_test/lib/src/native/native.dart @@ -0,0 +1,3 @@ +export 'maestro.dart'; +export 'notification.dart'; +export 'selector.dart'; diff --git a/packages/maestro_test/lib/src/native_widget.dart b/packages/maestro_test/lib/src/native/native_widget.dart similarity index 100% rename from packages/maestro_test/lib/src/native_widget.dart rename to packages/maestro_test/lib/src/native/native_widget.dart diff --git a/packages/maestro_test/lib/src/native_widget.freezed.dart b/packages/maestro_test/lib/src/native/native_widget.freezed.dart similarity index 100% rename from packages/maestro_test/lib/src/native_widget.freezed.dart rename to packages/maestro_test/lib/src/native/native_widget.freezed.dart diff --git a/packages/maestro_test/lib/src/native_widget.g.dart b/packages/maestro_test/lib/src/native/native_widget.g.dart similarity index 100% rename from packages/maestro_test/lib/src/native_widget.g.dart rename to packages/maestro_test/lib/src/native/native_widget.g.dart diff --git a/packages/maestro_test/lib/src/notification.dart b/packages/maestro_test/lib/src/native/notification.dart similarity index 91% rename from packages/maestro_test/lib/src/notification.dart rename to packages/maestro_test/lib/src/native/notification.dart index 17d6b41c3..e0b18bc2b 100644 --- a/packages/maestro_test/lib/src/notification.dart +++ b/packages/maestro_test/lib/src/native/notification.dart @@ -13,6 +13,7 @@ class Notification with _$Notification { required String content, }) = _Notification; + /// Creates a new [Notification] from JSON. factory Notification.fromJson(Map json) => _$NotificationFromJson(json); } diff --git a/packages/maestro_test/lib/src/notification.freezed.dart b/packages/maestro_test/lib/src/native/notification.freezed.dart similarity index 100% rename from packages/maestro_test/lib/src/notification.freezed.dart rename to packages/maestro_test/lib/src/native/notification.freezed.dart diff --git a/packages/maestro_test/lib/src/notification.g.dart b/packages/maestro_test/lib/src/native/notification.g.dart similarity index 100% rename from packages/maestro_test/lib/src/notification.g.dart rename to packages/maestro_test/lib/src/native/notification.g.dart diff --git a/packages/maestro_test/lib/src/selector.dart b/packages/maestro_test/lib/src/native/selector.dart similarity index 79% rename from packages/maestro_test/lib/src/selector.dart rename to packages/maestro_test/lib/src/native/selector.dart index d726d8fb3..a8226846f 100644 --- a/packages/maestro_test/lib/src/selector.dart +++ b/packages/maestro_test/lib/src/native/selector.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:maestro_test/src/custom_selectors/custom_selectors.dart'; part 'selector.freezed.dart'; part 'selector.g.dart'; @@ -31,8 +32,13 @@ class _AndroidWidgetClasses implements WidgetClasses { String get toggle => 'android.widget.Switch'; } +/// Matches widgets on the underlying native platform. +/// +/// This *does not* match Flutter widgets. If you want to use Maestro's _custom +/// selector_, see [MaestroTester] and [MaestroFinder]. @freezed class Selector with _$Selector { + /// Creates a new [Selector]. const factory Selector({ String? text, String? textStartsWith, @@ -48,6 +54,7 @@ class Selector with _$Selector { String? packageName, }) = _Selector; + /// Creates a new [Selector] from JSON. factory Selector.fromJson(Map json) => _$SelectorFromJson(json); } diff --git a/packages/maestro_test/lib/src/selector.freezed.dart b/packages/maestro_test/lib/src/native/selector.freezed.dart similarity index 100% rename from packages/maestro_test/lib/src/selector.freezed.dart rename to packages/maestro_test/lib/src/native/selector.freezed.dart diff --git a/packages/maestro_test/lib/src/selector.g.dart b/packages/maestro_test/lib/src/native/selector.g.dart similarity index 100% rename from packages/maestro_test/lib/src/selector.g.dart rename to packages/maestro_test/lib/src/native/selector.g.dart diff --git a/packages/maestro_test/test/custom_selectors_test.dart b/packages/maestro_test/test/custom_selectors_test.dart new file mode 100644 index 000000000..de2eac2c0 --- /dev/null +++ b/packages/maestro_test/test/custom_selectors_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maestro_test/src/custom_selectors/custom_selectors.dart'; + +void main() { + group('finds widget by', () { + maestroTest('type', ($) async { + await $.pumpWidgetAndSettle( + const MaterialApp( + home: Text('Hello'), + ), + ); + expect($(Text), findsOneWidget); + }); + + maestroTest('key', ($) async { + await $.pumpWidgetAndSettle( + const MaterialApp( + home: Text('Hello', key: Key('hello')), + ), + ); + expect($(#hello), findsOneWidget); + }); + + maestroTest('text', ($) async { + await $.pumpWidgetAndSettle( + const MaterialApp(home: Text('Hello')), + ); + expect($('Hello'), findsOneWidget); + }); + + maestroTest('text it contains', ($) async { + await $.pumpWidgetAndSettle( + const MaterialApp(home: Text('Hello')), + ); + expect($(RegExp('Hello')), findsOneWidget); + expect($(RegExp('Hell.*')), findsOneWidget); + expect($(RegExp('.*ello')), findsOneWidget); + expect($(RegExp('.*ell.*')), findsOneWidget); + }); + + maestroTest('icon', ($) async { + await $.pumpWidgetAndSettle( + const MaterialApp( + home: Icon(Icons.code), + ), + ); + + expect($(Icons.code), findsOneWidget); + }); + + maestroTest( + 'text using a nested MaestroFinder', + ($) async { + await $.pumpWidgetAndSettle( + const MaterialApp(home: Text('Hello')), + ); + expect($($('Hello')), findsOneWidget); + }, + ); + + maestroTest( + 'text using many nested MaestroFinders', + ($) async { + await $.pumpWidgetAndSettle( + const MaterialApp(home: Text('Hello')), + ); + + expect($($($($('Hello')))), findsOneWidget); + }, + ); + }); + + group('smoke tests', () { + Widget app() => MaterialApp( + key: const Key('app'), + home: Column( + children: [ + Container( + key: const Key('container'), + child: const Text('Hello 1', key: Key('helloText')), + ), + const SizedBox( + key: Key('sizedbox'), + child: Text('Hello 2', key: Key('helloText')), + ), + const SizedBox(child: Icon(Icons.code)), + ], + ), + ); + + maestroTest('finds by parent', ($) async { + await $.pumpWidgetAndSettle(app()); + + expect($(MaterialApp).$(Text), findsNWidgets(2)); + expect($(MaterialApp).$(#helloText), findsNWidgets(2)); + expect($(Container).$(Text), findsOneWidget); + expect($(SizedBox).$(Text), findsOneWidget); + expect($(Container).$('Hello 2'), findsNothing); + expect($(SizedBox).$('Hello 1'), findsNothing); + + expect($(MaterialApp).$(Container).$(Text), findsOneWidget); + expect($(MaterialApp).$(Container).$('Hello 1'), findsOneWidget); + expect($(MaterialApp).$(SizedBox).$('Hello 2'), findsOneWidget); + }); + + maestroTest('finds by parent and with descendant', ($) async { + await $.pumpWidgetAndSettle(app()); + + expect($(SizedBox).withDescendant(Text), findsOneWidget); + expect($(Column).withDescendant('Hello 2'), findsOneWidget); + }); + }); +}