diff --git a/packages/flutter_driver/lib/src/common/find.dart b/packages/flutter_driver/lib/src/common/find.dart index 690437174cf..ee16f78fa83 100644 --- a/packages/flutter_driver/lib/src/common/find.dart +++ b/packages/flutter_driver/lib/src/common/find.dart @@ -135,6 +135,7 @@ abstract class SerializableFinder { case 'ByType': return ByType.deserialize(json); case 'ByValueKey': return ByValueKey.deserialize(json); case 'ByTooltipMessage': return ByTooltipMessage.deserialize(json); + case 'BySemanticsLabel': return BySemanticsLabel.deserialize(json); case 'ByText': return ByText.deserialize(json); case 'PageBack': return PageBack(); } @@ -164,6 +165,44 @@ class ByTooltipMessage extends SerializableFinder { } } +/// A Flutter Driver finder that finds widgets by semantic label. +/// +/// If the [label] property is a [String], the finder will try to find an exact +/// match. If it is a [RegExp], it will return true for [RegExp.hasMatch]. +class BySemanticsLabel extends SerializableFinder { + /// Creates a semantic label finder given the [label]. + BySemanticsLabel(this.label); + + /// A [Pattern] matching the [Semantics.properties.label]. + /// + /// If this is a [String], it will be treated as an exact match. + final Pattern label; + + @override + final String finderType = 'BySemanticsLabel'; + + @override + Map serialize() { + if (label is RegExp) { + final RegExp regExp = label; + return super.serialize()..addAll({ + 'label': regExp.pattern, + 'isRegExp': 'true', + }); + } else { + return super.serialize()..addAll({ + 'label': label, + }); + } + } + + /// Deserializes the finder from JSON generated by [serialize]. + static BySemanticsLabel deserialize(Map json) { + final bool isRegExp = json['isRegExp'] == 'true'; + return BySemanticsLabel(isRegExp ? RegExp(json['label']) : json['label']); + } +} + /// A Flutter Driver finder that finds widgets by [text] inside a [Text] or /// [EditableText] widget. class ByText extends SerializableFinder { diff --git a/packages/flutter_driver/lib/src/driver/driver.dart b/packages/flutter_driver/lib/src/driver/driver.dart index 1924dde6b02..7a45a7b69dc 100644 --- a/packages/flutter_driver/lib/src/driver/driver.dart +++ b/packages/flutter_driver/lib/src/driver/driver.dart @@ -939,6 +939,9 @@ class CommonFinders { /// Finds widgets with a tooltip with the given [message]. SerializableFinder byTooltip(String message) => ByTooltipMessage(message); + /// Finds widgets with the given semantics [label]. + SerializableFinder bySemanticsLabel(Pattern label) => BySemanticsLabel(label); + /// Finds widgets whose class name matches the given string. SerializableFinder byType(String type) => ByType(type); diff --git a/packages/flutter_driver/lib/src/extension/extension.dart b/packages/flutter_driver/lib/src/extension/extension.dart index 7008f39973e..1d1bc5531ce 100644 --- a/packages/flutter_driver/lib/src/extension/extension.dart +++ b/packages/flutter_driver/lib/src/extension/extension.dart @@ -135,6 +135,7 @@ class FlutterDriverExtension { _finders.addAll({ 'ByText': (SerializableFinder finder) => _createByTextFinder(finder), 'ByTooltipMessage': (SerializableFinder finder) => _createByTooltipMessageFinder(finder), + 'BySemanticsLabel': (SerializableFinder finder) => _createBySemanticsLabelFinder(finder), 'ByValueKey': (SerializableFinder finder) => _createByValueKeyFinder(finder), 'ByType': (SerializableFinder finder) => _createByTypeFinder(finder), 'PageBack': (SerializableFinder finder) => _createPageBackFinder(), @@ -262,6 +263,22 @@ class FlutterDriverExtension { }, description: 'widget with text tooltip "${arguments.text}"'); } + Finder _createBySemanticsLabelFinder(BySemanticsLabel arguments) { + return find.byElementPredicate((Element element) { + if (element is! RenderObjectElement) { + return false; + } + final String semanticsLabel = element.renderObject?.debugSemantics?.label; + if (semanticsLabel == null) { + return false; + } + final Pattern label = arguments.label; + return label is RegExp + ? label.hasMatch(semanticsLabel) + : label == semanticsLabel; + }, description: 'widget with semantic label "${arguments.label}"'); + } + Finder _createByValueKeyFinder(ByValueKey arguments) { switch (arguments.keyValueType) { case 'int': diff --git a/packages/flutter_driver/test/flutter_driver_test.dart b/packages/flutter_driver/test/flutter_driver_test.dart index ad6a94a728f..e932047a7a2 100644 --- a/packages/flutter_driver/test/flutter_driver_test.dart +++ b/packages/flutter_driver/test/flutter_driver_test.dart @@ -158,6 +158,35 @@ void main() { }); }); + group('BySemanticsLabel', () { + test('finds by Semantic label using String', () async { + when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { + expect(i.positionalArguments[1], { + 'command': 'tap', + 'timeout': _kSerializedTestTimeout, + 'finderType': 'BySemanticsLabel', + 'label': 'foo', + }); + return makeMockResponse({}); + }); + await driver.tap(find.bySemanticsLabel('foo'), timeout: _kTestTimeout); + }); + + test('finds by Semantic label using RegExp', () async { + when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) { + expect(i.positionalArguments[1], { + 'command': 'tap', + 'timeout': _kSerializedTestTimeout, + 'finderType': 'BySemanticsLabel', + 'label': '^foo', + 'isRegExp': 'true', + }); + return makeMockResponse({}); + }); + await driver.tap(find.bySemanticsLabel(RegExp('^foo')), timeout: _kTestTimeout); + }); + }); + group('tap', () { test('requires a target reference', () async { expect(driver.tap(null), throwsA(isInstanceOf())); diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index 74801470fe8..4968f033185 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -272,6 +272,51 @@ class CommonFinders { Finder ancestor({ Finder of, Finder matching, bool matchRoot = false }) { return _AncestorFinder(of, matching, matchRoot: matchRoot); } + + /// Finds [Semantics] widgets matching the given `label`, either by + /// [RegExp.hasMatch] or string equality. + /// + /// The framework may combine semantics labels in certain scenarios, such as + /// when multiple [Text] widgets are in a [MaterialButton] widget. In such a + /// case, it may be preferable to match by regular expression. Consumers of + /// this API __must not__ introduce unsuitable content into the semantics tree + /// for the purposes of testing; in particular, you should prefer matching by + /// regular expression rather than by string if the framework has combined + /// your semantics, and not try to force the framework to break up the + /// semantics nodes. Breaking up the nodes would have an undesirable effect on + /// screen readers and other accessibility services. + /// + /// ## Sample code + /// + /// ```dart + /// expect(find.BySemanticsLabel('Back'), findsOneWidget); + /// ``` + /// + /// If the `skipOffstage` argument is true (the default), then this skips + /// nodes that are [Offstage] or that are from inactive [Route]s. + Finder bySemanticsLabel(Pattern label, { bool skipOffstage = true }) { + if (WidgetsBinding.instance.pipelineOwner.semanticsOwner == null) + throw StateError('Semantics are not enabled. ' + 'Make sure to call tester.enableSemantics() before using ' + 'this finder, and call dispose on its return value after.'); + return byElementPredicate( + (Element element) { + // Multiple elements can have the same renderObject - we want the "owner" + // of the renderObject, i.e. the RenderObjectElement. + if (element is! RenderObjectElement) { + return false; + } + final String semanticsLabel = element.renderObject?.debugSemantics?.label; + if (semanticsLabel == null) { + return false; + } + return label is RegExp + ? label.hasMatch(semanticsLabel) + : label == semanticsLabel; + }, + skipOffstage: skipOffstage, + ); + } } /// Searches a widget tree and returns nodes that match a particular diff --git a/packages/flutter_test/test/finders_test.dart b/packages/flutter_test/test/finders_test.dart index 222ba05a90c..93c2671dd2f 100644 --- a/packages/flutter_test/test/finders_test.dart +++ b/packages/flutter_test/test/finders_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/rendering.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -29,6 +30,53 @@ void main() { }); }); + group('semantics', () { + testWidgets('Throws StateError if semantics are not enabled', (WidgetTester tester) async { + expect(() => find.bySemanticsLabel('Add'), throwsStateError); + }); + + testWidgets('finds Semantically labeled widgets', (WidgetTester tester) async { + final SemanticsHandle semanticsHandle = tester.ensureSemantics(); + await tester.pumpWidget(_boilerplate( + Semantics( + label: 'Add', + button: true, + child: const FlatButton( + child: Text('+'), + onPressed: null, + ), + ), + )); + expect(find.bySemanticsLabel('Add'), findsOneWidget); + semanticsHandle.dispose(); + }); + + testWidgets('finds Semantically labeled widgets by RegExp', (WidgetTester tester) async { + final SemanticsHandle semanticsHandle = tester.ensureSemantics(); + await tester.pumpWidget(_boilerplate( + Semantics( + container: true, + child: Row(children: const [ + Text('Hello'), + Text('World'), + ]), + ), + )); + expect(find.bySemanticsLabel('Hello'), findsNothing); + expect(find.bySemanticsLabel(RegExp(r'^Hello')), findsOneWidget); + semanticsHandle.dispose(); + }); + + testWidgets('finds Semantically labeled widgets without explicit Semantics', (WidgetTester tester) async { + final SemanticsHandle semanticsHandle = tester.ensureSemantics(); + await tester.pumpWidget(_boilerplate( + const SimpleCustomSemanticsWidget('Foo') + )); + expect(find.bySemanticsLabel('Foo'), findsOneWidget); + semanticsHandle.dispose(); + }); + }); + group('hitTestable', () { testWidgets('excludes non-hit-testable widgets', (WidgetTester tester) async { await tester.pumpWidget( @@ -92,3 +140,28 @@ Widget _boilerplate(Widget child) { child: child, ); } + +class SimpleCustomSemanticsWidget extends LeafRenderObjectWidget { + const SimpleCustomSemanticsWidget(this.label); + + final String label; + + @override + RenderObject createRenderObject(BuildContext context) => SimpleCustomSemanticsRenderObject(label); +} + +class SimpleCustomSemanticsRenderObject extends RenderBox { + SimpleCustomSemanticsRenderObject(this.label); + + final String label; + + @override + bool get sizedByParent => true; + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + config..label = label..textDirection = TextDirection.ltr; + + } +} \ No newline at end of file