diff --git a/packages/flutter_driver/lib/driver_extension.dart b/packages/flutter_driver/lib/driver_extension.dart index 13ae916447..fce9ad8bec 100644 --- a/packages/flutter_driver/lib/driver_extension.dart +++ b/packages/flutter_driver/lib/driver_extension.dart @@ -24,5 +24,6 @@ /// } library flutter_driver_extension; -export 'src/common/create_finder_factory.dart'; +export 'src/common/deserialization_factory.dart'; +export 'src/common/handler_factory.dart'; export 'src/extension/extension.dart'; diff --git a/packages/flutter_driver/lib/flutter_driver.dart b/packages/flutter_driver/lib/flutter_driver.dart index 46ebb8c7ba..c7eddfeebb 100644 --- a/packages/flutter_driver/lib/flutter_driver.dart +++ b/packages/flutter_driver/lib/flutter_driver.dart @@ -13,6 +13,7 @@ /// Protractor (Angular), Espresso (Android) or Earl Gray (iOS). library flutter_driver; +export 'src/common/deserialization_factory.dart'; export 'src/common/diagnostics_tree.dart'; export 'src/common/enum_util.dart'; export 'src/common/error.dart'; diff --git a/packages/flutter_driver/lib/src/common/create_finder_factory.dart b/packages/flutter_driver/lib/src/common/create_finder_factory.dart deleted file mode 100644 index 36c3d27e72..0000000000 --- a/packages/flutter_driver/lib/src/common/create_finder_factory.dart +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; - -import 'error.dart'; -import 'find.dart'; - -/// A factory which creates [Finder]s from [SerializableFinder]s. -mixin CreateFinderFactory { - /// Creates the flutter widget finder from [SerializableFinder]. - Finder createFinder(SerializableFinder finder) { - final String finderType = finder.finderType; - switch (finderType) { - case 'ByText': - return _createByTextFinder(finder as ByText); - case 'ByTooltipMessage': - return _createByTooltipMessageFinder(finder as ByTooltipMessage); - case 'BySemanticsLabel': - return _createBySemanticsLabelFinder(finder as BySemanticsLabel); - case 'ByValueKey': - return _createByValueKeyFinder(finder as ByValueKey); - case 'ByType': - return _createByTypeFinder(finder as ByType); - case 'PageBack': - return _createPageBackFinder(); - case 'Ancestor': - return _createAncestorFinder(finder as Ancestor); - case 'Descendant': - return _createDescendantFinder(finder as Descendant); - default: - throw DriverError('Unsupported search specification type $finderType'); - } - } - - Finder _createByTextFinder(ByText arguments) { - return find.text(arguments.text); - } - - Finder _createByTooltipMessageFinder(ByTooltipMessage arguments) { - return find.byElementPredicate((Element element) { - final Widget widget = element.widget; - if (widget is Tooltip) { - return widget.message == arguments.text; - } - return false; - }, 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': - return find.byKey(ValueKey(arguments.keyValue as int)); - case 'String': - return find.byKey(ValueKey(arguments.keyValue as String)); - default: - throw 'Unsupported ByValueKey type: ${arguments.keyValueType}'; - } - } - - Finder _createByTypeFinder(ByType arguments) { - return find.byElementPredicate((Element element) { - return element.widget.runtimeType.toString() == arguments.type; - }, description: 'widget with runtimeType "${arguments.type}"'); - } - - Finder _createPageBackFinder() { - return find.byElementPredicate((Element element) { - final Widget widget = element.widget; - if (widget is Tooltip) { - return widget.message == 'Back'; - } - if (widget is CupertinoNavigationBarBackButton) { - return true; - } - return false; - }, description: 'Material or Cupertino back button'); - } - - Finder _createAncestorFinder(Ancestor arguments) { - final Finder finder = find.ancestor( - of: createFinder(arguments.of), - matching: createFinder(arguments.matching), - matchRoot: arguments.matchRoot, - ); - return arguments.firstMatchOnly ? finder.first : finder; - } - - Finder _createDescendantFinder(Descendant arguments) { - final Finder finder = find.descendant( - of: createFinder(arguments.of), - matching: createFinder(arguments.matching), - matchRoot: arguments.matchRoot, - ); - return arguments.firstMatchOnly ? finder.first : finder; - } -} diff --git a/packages/flutter_driver/lib/src/common/deserialization_factory.dart b/packages/flutter_driver/lib/src/common/deserialization_factory.dart new file mode 100644 index 0000000000..b2e8b95822 --- /dev/null +++ b/packages/flutter_driver/lib/src/common/deserialization_factory.dart @@ -0,0 +1,70 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'diagnostics_tree.dart'; +import 'error.dart'; +import 'find.dart'; +import 'frame_sync.dart'; +import 'geometry.dart'; +import 'gesture.dart'; +import 'health.dart'; +import 'layer_tree.dart'; +import 'message.dart'; +import 'render_tree.dart'; +import 'request_data.dart'; +import 'semantics.dart'; +import 'text.dart'; +import 'wait.dart'; + +/// A factory for deserializing [Finder]s. +mixin DeserializeFinderFactory { + /// Deserializes the finder from JSON generated by [SerializableFinder.serialize]. + SerializableFinder deserializeFinder(Map json) { + final String? finderType = json['finderType']; + switch (finderType) { + 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 const PageBack(); + case 'Descendant': return Descendant.deserialize(json, this); + case 'Ancestor': return Ancestor.deserialize(json, this); + } + throw DriverError('Unsupported search specification type $finderType'); + } +} + +/// A factory for deserializing [Command]s. +mixin DeserializeCommandFactory { + /// Deserializes the finder from JSON generated by [Command.serialize] or [CommandWithTarget.serialize]. + Command deserializeCommand(Map params, DeserializeFinderFactory finderFactory) { + final String? kind = params['command']; + switch(kind) { + case 'get_health': return GetHealth.deserialize(params); + case 'get_layer_tree': return GetLayerTree.deserialize(params); + case 'get_render_tree': return GetRenderTree.deserialize(params); + case 'enter_text': return EnterText.deserialize(params); + case 'get_text': return GetText.deserialize(params, finderFactory); + case 'request_data': return RequestData.deserialize(params); + case 'scroll': return Scroll.deserialize(params, finderFactory); + case 'scrollIntoView': return ScrollIntoView.deserialize(params, finderFactory); + case 'set_frame_sync': return SetFrameSync.deserialize(params); + case 'set_semantics': return SetSemantics.deserialize(params); + case 'set_text_entry_emulation': return SetTextEntryEmulation.deserialize(params); + case 'tap': return Tap.deserialize(params, finderFactory); + case 'waitFor': return WaitFor.deserialize(params, finderFactory); + case 'waitForAbsent': return WaitForAbsent.deserialize(params, finderFactory); + case 'waitForCondition': return WaitForCondition.deserialize(params); + case 'waitUntilNoTransientCallbacks': return WaitUntilNoTransientCallbacks.deserialize(params); + case 'waitUntilNoPendingFrame': return WaitUntilNoPendingFrame.deserialize(params); + case 'waitUntilFirstFrameRasterized': return WaitUntilFirstFrameRasterized.deserialize(params); + case 'get_semantics_id': return GetSemanticsId.deserialize(params, finderFactory); + case 'get_offset': return GetOffset.deserialize(params, finderFactory); + case 'get_diagnostics_tree': return GetDiagnosticsTree.deserialize(params, finderFactory); + } + + throw DriverError('Unsupported command kind $kind'); + } +} diff --git a/packages/flutter_driver/lib/src/common/diagnostics_tree.dart b/packages/flutter_driver/lib/src/common/diagnostics_tree.dart index ec4b53de94..5bcaa8a65f 100644 --- a/packages/flutter_driver/lib/src/common/diagnostics_tree.dart +++ b/packages/flutter_driver/lib/src/common/diagnostics_tree.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 'deserialization_factory.dart'; import 'enum_util.dart'; import 'find.dart'; import 'message.dart'; diff --git a/packages/flutter_driver/lib/src/common/find.dart b/packages/flutter_driver/lib/src/common/find.dart index bdb6ac277d..01bd838e6c 100644 --- a/packages/flutter_driver/lib/src/common/find.dart +++ b/packages/flutter_driver/lib/src/common/find.dart @@ -6,28 +6,10 @@ import 'dart:convert'; import 'package:meta/meta.dart'; +import 'deserialization_factory.dart'; import 'error.dart'; import 'message.dart'; -/// A factory for deserializing [Finder]s. -mixin DeserializeFinderFactory { - /// Deserializes the finder from JSON generated by [SerializableFinder.serialize]. - SerializableFinder deserializeFinder(Map json) { - final String? finderType = json['finderType']; - switch (finderType) { - 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 const PageBack(); - case 'Descendant': return Descendant.deserialize(json, this); - case 'Ancestor': return Ancestor.deserialize(json, this); - } - throw DriverError('Unsupported search specification type $finderType'); - } -} - const List _supportedKeyValueTypes = [String, int]; DriverError _createInvalidKeyValueTypeError(String invalidType) { diff --git a/packages/flutter_driver/lib/src/common/geometry.dart b/packages/flutter_driver/lib/src/common/geometry.dart index c8e4a325ae..aafcd0600d 100644 --- a/packages/flutter_driver/lib/src/common/geometry.dart +++ b/packages/flutter_driver/lib/src/common/geometry.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 'deserialization_factory.dart'; import 'enum_util.dart'; import 'find.dart'; import 'message.dart'; diff --git a/packages/flutter_driver/lib/src/common/gesture.dart b/packages/flutter_driver/lib/src/common/gesture.dart index 5a66767647..edd87c84b1 100644 --- a/packages/flutter_driver/lib/src/common/gesture.dart +++ b/packages/flutter_driver/lib/src/common/gesture.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 'deserialization_factory.dart'; import 'find.dart'; import 'message.dart'; diff --git a/packages/flutter_driver/lib/src/common/handler_factory.dart b/packages/flutter_driver/lib/src/common/handler_factory.dart new file mode 100644 index 0000000000..22eb52f3d9 --- /dev/null +++ b/packages/flutter_driver/lib/src/common/handler_factory.dart @@ -0,0 +1,492 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_driver/src/extension/wait_conditions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'diagnostics_tree.dart'; +import 'error.dart'; +import 'find.dart'; +import 'frame_sync.dart'; +import 'geometry.dart'; +import 'gesture.dart'; +import 'health.dart'; +import 'layer_tree.dart'; +import 'message.dart'; +import 'render_tree.dart'; +import 'request_data.dart'; +import 'semantics.dart'; +import 'text.dart'; +import 'wait.dart'; + +/// A factory which creates [Finder]s from [SerializableFinder]s. +mixin CreateFinderFactory { + /// Creates the flutter widget finder from [SerializableFinder]. + Finder createFinder(SerializableFinder finder) { + final String finderType = finder.finderType; + switch (finderType) { + case 'ByText': + return _createByTextFinder(finder as ByText); + case 'ByTooltipMessage': + return _createByTooltipMessageFinder(finder as ByTooltipMessage); + case 'BySemanticsLabel': + return _createBySemanticsLabelFinder(finder as BySemanticsLabel); + case 'ByValueKey': + return _createByValueKeyFinder(finder as ByValueKey); + case 'ByType': + return _createByTypeFinder(finder as ByType); + case 'PageBack': + return _createPageBackFinder(); + case 'Ancestor': + return _createAncestorFinder(finder as Ancestor); + case 'Descendant': + return _createDescendantFinder(finder as Descendant); + default: + throw DriverError('Unsupported search specification type $finderType'); + } + } + + Finder _createByTextFinder(ByText arguments) { + return find.text(arguments.text); + } + + Finder _createByTooltipMessageFinder(ByTooltipMessage arguments) { + return find.byElementPredicate((Element element) { + final Widget widget = element.widget; + if (widget is Tooltip) { + return widget.message == arguments.text; + } + return false; + }, 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': + return find.byKey(ValueKey(arguments.keyValue as int)); + case 'String': + return find.byKey(ValueKey(arguments.keyValue as String)); + default: + throw 'Unsupported ByValueKey type: ${arguments.keyValueType}'; + } + } + + Finder _createByTypeFinder(ByType arguments) { + return find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == arguments.type; + }, description: 'widget with runtimeType "${arguments.type}"'); + } + + Finder _createPageBackFinder() { + return find.byElementPredicate((Element element) { + final Widget widget = element.widget; + if (widget is Tooltip) { + return widget.message == 'Back'; + } + if (widget is CupertinoNavigationBarBackButton) { + return true; + } + return false; + }, description: 'Material or Cupertino back button'); + } + + Finder _createAncestorFinder(Ancestor arguments) { + final Finder finder = find.ancestor( + of: createFinder(arguments.of), + matching: createFinder(arguments.matching), + matchRoot: arguments.matchRoot, + ); + return arguments.firstMatchOnly ? finder.first : finder; + } + + Finder _createDescendantFinder(Descendant arguments) { + final Finder finder = find.descendant( + of: createFinder(arguments.of), + matching: createFinder(arguments.matching), + matchRoot: arguments.matchRoot, + ); + return arguments.firstMatchOnly ? finder.first : finder; + } +} + +/// A factory for [Command] handlers. +mixin CommandHandlerFactory { + /// With [_frameSync] enabled, Flutter Driver will wait to perform an action + /// until there are no pending frames in the app under test. + bool _frameSync = true; + + /// Gets [DataHandler] for result delivery. + @protected + DataHandler? getDataHandler() => null; + + /// Registers text input emulation. + @protected + void registerTextInput() { + _testTextInput.register(); + } + + final TestTextInput _testTextInput = TestTextInput(); + + /// Deserializes the finder from JSON generated by [Command.serialize] or [CommandWithTarget.serialize]. + Future handleCommand(Command command, WidgetController prober, CreateFinderFactory finderFactory) { + switch(command.kind) { + case 'get_health': return _getHealth(command); + case 'get_layer_tree': return _getLayerTree(command); + case 'get_render_tree': return _getRenderTree(command); + case 'enter_text': return _enterText(command); + case 'get_text': return _getText(command, finderFactory); + case 'request_data': return _requestData(command); + case 'scroll': return _scroll(command, prober, finderFactory); + case 'scrollIntoView': return _scrollIntoView(command, finderFactory); + case 'set_frame_sync': return _setFrameSync(command); + case 'set_semantics': return _setSemantics(command); + case 'set_text_entry_emulation': return _setTextEntryEmulation(command); + case 'tap': return _tap(command, prober, finderFactory); + case 'waitFor': return _waitFor(command, finderFactory); + case 'waitForAbsent': return _waitForAbsent(command, finderFactory); + case 'waitForCondition': return _waitForCondition(command); + case 'waitUntilNoTransientCallbacks': return _waitUntilNoTransientCallbacks(command); + case 'waitUntilNoPendingFrame': return _waitUntilNoPendingFrame(command); + case 'waitUntilFirstFrameRasterized': return _waitUntilFirstFrameRasterized(command); + case 'get_semantics_id': return _getSemanticsId(command, finderFactory); + case 'get_offset': return _getOffset(command, finderFactory); + case 'get_diagnostics_tree': return _getDiagnosticsTree(command, finderFactory); + } + + throw DriverError('Unsupported command kind ${command.kind}'); + } + + Future _getHealth(Command command) async => const Health(HealthStatus.ok); + + Future _getLayerTree(Command command) async { + return LayerTree(RendererBinding.instance?.renderView.debugLayer?.toStringDeep()); + } + + Future _getRenderTree(Command command) async { + return RenderTree(RendererBinding.instance?.renderView.toStringDeep()); + } + + Future _enterText(Command command) async { + if (!_testTextInput.isRegistered) { + throw 'Unable to fulfill `FlutterDriver.enterText`. Text emulation is ' + 'disabled. You can enable it using `FlutterDriver.setTextEntryEmulation`.'; + } + final EnterText enterTextCommand = command as EnterText; + _testTextInput.enterText(enterTextCommand.text); + return const EnterTextResult(); + } + + Future _requestData(Command command) async { + final RequestData requestDataCommand = command as RequestData; + final DataHandler? dataHandler = getDataHandler(); + return RequestDataResult(dataHandler == null + ? 'No requestData Extension registered' + : await dataHandler(requestDataCommand.message)); + } + + Future _setFrameSync(Command command) async { + final SetFrameSync setFrameSyncCommand = command as SetFrameSync; + _frameSync = setFrameSyncCommand.enabled; + return const SetFrameSyncResult(); + } + + Future _tap(Command command, WidgetController prober, CreateFinderFactory finderFactory) async { + final Tap tapCommand = command as Tap; + final Finder computedFinder = await waitForElement( + finderFactory.createFinder(tapCommand.finder).hitTestable(), + ); + await prober.tap(computedFinder); + return const TapResult(); + } + + Future _waitFor(Command command, CreateFinderFactory finderFactory) async { + final WaitFor waitForCommand = command as WaitFor; + await waitForElement(finderFactory.createFinder(waitForCommand.finder)); + return const WaitForResult(); + } + + Future _waitForAbsent(Command command, CreateFinderFactory finderFactory) async { + final WaitForAbsent waitForAbsentCommand = command as WaitForAbsent; + await waitForAbsentElement(finderFactory.createFinder(waitForAbsentCommand.finder)); + return const WaitForAbsentResult(); + } + + Future _waitForCondition(Command command) async { + assert(command != null); + final WaitForCondition waitForConditionCommand = command as WaitForCondition; + final WaitCondition condition = deserializeCondition(waitForConditionCommand.condition); + await condition.wait(); + return null; + } + + @Deprecated( + 'This method has been deprecated in favor of _waitForCondition. ' + 'This feature was deprecated after v1.9.3.' + ) + Future _waitUntilNoTransientCallbacks(Command command) async { + if (SchedulerBinding.instance!.transientCallbackCount != 0) + await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); + return null; + } + + /// Returns a future that waits until no pending frame is scheduled (frame is synced). + /// + /// Specifically, it checks: + /// * Whether the count of transient callbacks is zero. + /// * Whether there's no pending request for scheduling a new frame. + /// + /// We consider the frame is synced when both conditions are met. + /// + /// This method relies on a Flutter Driver mechanism called "frame sync", + /// which waits for transient animations to finish. Persistent animations will + /// cause this to wait forever. + /// + /// If a test needs to interact with the app while animations are running, it + /// should avoid this method and instead disable the frame sync using + /// `set_frame_sync` method. See [FlutterDriver.runUnsynchronized] for more + /// details on how to do this. Note, disabling frame sync will require the + /// test author to use some other method to avoid flakiness. + /// + /// This method has been deprecated in favor of [_waitForCondition]. + @Deprecated( + 'This method has been deprecated in favor of _waitForCondition. ' + 'This feature was deprecated after v1.9.3.' + ) + Future _waitUntilNoPendingFrame(Command command) async { + await _waitUntilFrame(() { + return SchedulerBinding.instance!.transientCallbackCount == 0 + && !SchedulerBinding.instance!.hasScheduledFrame; + }); + return null; + } + + Future _getSemanticsId(Command command, CreateFinderFactory finderFactory) async { + final GetSemanticsId semanticsCommand = command as GetSemanticsId; + final Finder target = await waitForElement(finderFactory.createFinder(semanticsCommand.finder)); + final Iterable elements = target.evaluate(); + if (elements.length > 1) { + throw StateError('Found more than one element with the same ID: $elements'); + } + final Element element = elements.single; + RenderObject? renderObject = element.renderObject; + SemanticsNode? node; + while (renderObject != null && node == null) { + node = renderObject.debugSemantics; + renderObject = renderObject.parent as RenderObject?; + } + if (node == null) + throw StateError('No semantics data found'); + return GetSemanticsIdResult(node.id); + } + + Future _getOffset(Command command, CreateFinderFactory finderFactory) async { + final GetOffset getOffsetCommand = command as GetOffset; + final Finder finder = await waitForElement(finderFactory.createFinder(getOffsetCommand.finder)); + final Element element = finder.evaluate().single; + final RenderBox box = (element.renderObject as RenderBox?)!; + Offset localPoint; + switch (getOffsetCommand.offsetType) { + case OffsetType.topLeft: + localPoint = Offset.zero; + break; + case OffsetType.topRight: + localPoint = box.size.topRight(Offset.zero); + break; + case OffsetType.bottomLeft: + localPoint = box.size.bottomLeft(Offset.zero); + break; + case OffsetType.bottomRight: + localPoint = box.size.bottomRight(Offset.zero); + break; + case OffsetType.center: + localPoint = box.size.center(Offset.zero); + break; + } + final Offset globalPoint = box.localToGlobal(localPoint); + return GetOffsetResult(dx: globalPoint.dx, dy: globalPoint.dy); + } + + Future _getDiagnosticsTree(Command command, CreateFinderFactory finderFactory) async { + final GetDiagnosticsTree diagnosticsCommand = command as GetDiagnosticsTree; + final Finder finder = await waitForElement(finderFactory.createFinder(diagnosticsCommand.finder)); + final Element element = finder.evaluate().single; + DiagnosticsNode diagnosticsNode; + switch (diagnosticsCommand.diagnosticsType) { + case DiagnosticsType.renderObject: + diagnosticsNode = element.renderObject!.toDiagnosticsNode(); + break; + case DiagnosticsType.widget: + diagnosticsNode = element.toDiagnosticsNode(); + break; + } + return DiagnosticsTreeResult(diagnosticsNode.toJsonMap(DiagnosticsSerializationDelegate( + subtreeDepth: diagnosticsCommand.subtreeDepth, + includeProperties: diagnosticsCommand.includeProperties, + ))); + } + + Future _scroll(Command command, WidgetController _prober, CreateFinderFactory finderFactory) async { + final Scroll scrollCommand = command as Scroll; + final Finder target = await waitForElement(finderFactory.createFinder(scrollCommand.finder)); + final int totalMoves = scrollCommand.duration.inMicroseconds * scrollCommand.frequency ~/ Duration.microsecondsPerSecond; + final Offset delta = Offset(scrollCommand.dx, scrollCommand.dy) / totalMoves.toDouble(); + final Duration pause = scrollCommand.duration ~/ totalMoves; + final Offset startLocation = _prober.getCenter(target); + Offset currentLocation = startLocation; + final TestPointer pointer = TestPointer(1); + _prober.binding.handlePointerEvent(pointer.down(startLocation)); + await Future.value(); // so that down and move don't happen in the same microtask + for (int moves = 0; moves < totalMoves; moves += 1) { + currentLocation = currentLocation + delta; + _prober.binding.handlePointerEvent(pointer.move(currentLocation)); + await Future.delayed(pause); + } + _prober.binding.handlePointerEvent(pointer.up()); + + return const ScrollResult(); + } + + Future _scrollIntoView(Command command, CreateFinderFactory finderFactory) async { + final ScrollIntoView scrollIntoViewCommand = command as ScrollIntoView; + final Finder target = await waitForElement(finderFactory.createFinder(scrollIntoViewCommand.finder)); + await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: scrollIntoViewCommand.alignment); + return const ScrollResult(); + } + + Future _getText(Command command, CreateFinderFactory finderFactory) async { + final GetText getTextCommand = command as GetText; + final Finder target = await waitForElement(finderFactory.createFinder(getTextCommand.finder)); + + final Widget widget = target.evaluate().single.widget; + String? text; + + if (widget.runtimeType == Text) { + text = (widget as Text).data; + } else if (widget.runtimeType == RichText) { + final RichText richText = widget as RichText; + if (richText.text.runtimeType == TextSpan) { + text = (richText.text as TextSpan).text; + } + } else if (widget.runtimeType == TextField) { + text = (widget as TextField).controller?.text; + } else if (widget.runtimeType == TextFormField) { + text = (widget as TextFormField).controller?.text; + } else if (widget.runtimeType == EditableText) { + text = (widget as EditableText).controller.text; + } + + if (text == null) { + throw UnsupportedError('Type ${widget.runtimeType.toString()} is currently not supported by getText'); + } + + return GetTextResult(text); + } + + Future _setTextEntryEmulation(Command command) async { + final SetTextEntryEmulation setTextEntryEmulationCommand = command as SetTextEntryEmulation; + if (setTextEntryEmulationCommand.enabled) { + _testTextInput.register(); + } else { + _testTextInput.unregister(); + } + return const SetTextEntryEmulationResult(); + } + + SemanticsHandle? _semantics; + bool get _semanticsIsEnabled => RendererBinding.instance!.pipelineOwner.semanticsOwner != null; + + Future _setSemantics(Command command) async { + final SetSemantics setSemanticsCommand = command as SetSemantics; + final bool semanticsWasEnabled = _semanticsIsEnabled; + if (setSemanticsCommand.enabled && _semantics == null) { + _semantics = RendererBinding.instance!.pipelineOwner.ensureSemantics(); + if (!semanticsWasEnabled) { + // wait for the first frame where semantics is enabled. + final Completer completer = Completer(); + SchedulerBinding.instance!.addPostFrameCallback((Duration d) { + completer.complete(); + }); + await completer.future; + } + } else if (!setSemanticsCommand.enabled && _semantics != null) { + _semantics!.dispose(); + _semantics = null; + } + return SetSemanticsResult(semanticsWasEnabled != _semanticsIsEnabled); + } + + // This can be used to wait for the first frame being rasterized during app launch. + @Deprecated( + 'This method has been deprecated in favor of _waitForCondition. ' + 'This feature was deprecated after v1.9.3.' + ) + Future _waitUntilFirstFrameRasterized(Command command) async { + await WidgetsBinding.instance!.waitUntilFirstFrameRasterized; + return null; + } + + /// Runs `finder` repeatedly until it finds one or more [Element]s. + Future waitForElement(Finder finder) async { + if (_frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); + + await _waitUntilFrame(() => finder.evaluate().isNotEmpty); + + if (_frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); + + return finder; + } + + /// Runs `finder` repeatedly until it finds zero [Element]s. + Future waitForAbsentElement(Finder finder) async { + if (_frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); + + await _waitUntilFrame(() => finder.evaluate().isEmpty); + + if (_frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); + + return finder; + } + + // Waits until at the end of a frame the provided [condition] is [true]. + Future _waitUntilFrame(bool condition(), [ Completer? completer ]) { + completer ??= Completer(); + if (!condition()) { + SchedulerBinding.instance!.addPostFrameCallback((Duration timestamp) { + _waitUntilFrame(condition, completer); + }); + } else { + completer.complete(); + } + return completer.future; + } +} diff --git a/packages/flutter_driver/lib/src/common/text.dart b/packages/flutter_driver/lib/src/common/text.dart index 6ccaf846f3..a394fbe549 100644 --- a/packages/flutter_driver/lib/src/common/text.dart +++ b/packages/flutter_driver/lib/src/common/text.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 'deserialization_factory.dart'; import 'find.dart'; import 'message.dart'; diff --git a/packages/flutter_driver/lib/src/extension/extension.dart b/packages/flutter_driver/lib/src/extension/extension.dart index 44bc9622bd..324decc9d4 100644 --- a/packages/flutter_driver/lib/src/extension/extension.dart +++ b/packages/flutter_driver/lib/src/extension/extension.dart @@ -9,33 +9,21 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show RendererBinding, SemanticsHandle; +import 'package:flutter/rendering.dart' show RendererBinding; import 'package:flutter/scheduler.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../common/create_finder_factory.dart'; -import '../common/diagnostics_tree.dart'; +import '../common/deserialization_factory.dart'; import '../common/error.dart'; import '../common/find.dart'; -import '../common/frame_sync.dart'; -import '../common/geometry.dart'; -import '../common/gesture.dart'; -import '../common/health.dart'; -import '../common/layer_tree.dart'; +import '../common/handler_factory.dart'; import '../common/message.dart'; -import '../common/render_tree.dart'; -import '../common/request_data.dart'; -import '../common/semantics.dart'; -import '../common/text.dart'; -import '../common/wait.dart'; import '_extension_io.dart' if (dart.library.html) '_extension_web.dart'; -import 'wait_conditions.dart'; const String _extensionMethodName = 'driver'; -const String _extensionMethod = 'ext.flutter.$_extensionMethodName'; /// Signature for the handler passed to [enableFlutterDriverExtension]. /// @@ -44,16 +32,17 @@ const String _extensionMethod = 'ext.flutter.$_extensionMethodName'; typedef DataHandler = Future Function(String? message); class _DriverBinding extends BindingBase with SchedulerBinding, ServicesBinding, GestureBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding { - _DriverBinding(this._handler, this._silenceErrors, this.finders); + _DriverBinding(this._handler, this._silenceErrors, this.finders, this.commands); final DataHandler? _handler; final bool _silenceErrors; final List? finders; + final List? commands; @override void initServiceExtensions() { super.initServiceExtensions(); - final FlutterDriverExtension extension = FlutterDriverExtension(_handler, _silenceErrors, finders: finders ?? const []); + final FlutterDriverExtension extension = FlutterDriverExtension(_handler, _silenceErrors, finders: finders ?? const [], commands: commands ?? const []); registerServiceExtension( name: _extensionMethodName, callback: extension.call, @@ -89,24 +78,36 @@ class _DriverBinding extends BindingBase with SchedulerBinding, ServicesBinding, /// will still be returned in the `response` field of the result JSON along /// with an `isError` boolean. /// -/// The `finders` parameter are used to add custom finders, as in the following example. +/// The `finders` and `commands` parameters are optional and used to add custom +/// finders or commands, as in the following example. /// /// ```dart main /// void main() { -/// enableFlutterDriverExtension(finders: [ SomeFinderExtension() ]); +/// enableFlutterDriverExtension( +/// finders: [ SomeFinderExtension() ], +/// commands: [ SomeCommandExtension() ], +/// ); /// /// app.main(); /// } /// ``` /// /// ```dart -/// class Some extends SerializableFinder { -/// const Some(this.title); +/// driver.sendCommand(SomeCommand(ByValueKey('Button'), 7)); +/// ``` +/// +/// Note: SomeFinder and SomeFinderExtension must be placed in different files +/// to avoid `dart:ui` import issue. Imports relative to `dart:ui` can't be +/// accessed from host runner, where flutter runtime is not accessible. +/// +/// ```dart +/// class SomeFinder extends SerializableFinder { +/// const SomeFinder(this.title); /// /// final String title; /// /// @override -/// String get finderType => 'Some'; +/// String get finderType => 'SomeFinder'; /// /// @override /// Map serialize() => super.serialize()..addAll({ @@ -118,14 +119,14 @@ class _DriverBinding extends BindingBase with SchedulerBinding, ServicesBinding, /// ```dart /// class SomeFinderExtension extends FinderExtension { /// -/// String get finderType => 'Some'; +/// String get finderType => 'SomeFinder'; /// /// SerializableFinder deserialize(Map params, DeserializeFinderFactory finderFactory) { -/// return Some(json['title']); +/// return SomeFinder(json['title']); /// } /// /// Finder createFinder(SerializableFinder finder, CreateFinderFactory finderFactory) { -/// Some someFinder = finder as Some; +/// Some someFinder = finder as SomeFinder; /// /// return find.byElementPredicate((Element element) { /// final Widget widget = element.widget; @@ -138,9 +139,87 @@ class _DriverBinding extends BindingBase with SchedulerBinding, ServicesBinding, /// } /// ``` /// -void enableFlutterDriverExtension({ DataHandler? handler, bool silenceErrors = false, List? finders}) { +/// Note: SomeCommand, SomeResult and SomeCommandExtension must be placed in +/// different files to avoid `dart:ui` import issue. Imports relative to `dart:ui` +/// can't be accessed from host runner, where flutter runtime is not accessible. +/// +/// ```dart +/// class SomeCommand extends CommandWithTarget { +/// SomeCommand(SerializableFinder finder, this.times, {Duration? timeout}) +/// : super(finder, timeout: timeout); +/// +/// SomeCommand.deserialize(Map json, DeserializeFinderFactory finderFactory) +/// : times = int.parse(json['times']!), +/// super.deserialize(json, finderFactory); +/// +/// @override +/// Map serialize() { +/// return super.serialize()..addAll({'times': '$times'}); +/// } +/// +/// @override +/// String get kind => 'SomeCommand'; +/// +/// final int times; +/// } +///``` +/// +/// ```dart +/// class SomeCommandResult extends Result { +/// const SomeCommandResult(this.resultParam); +/// +/// final String resultParam; +/// +/// @override +/// Map toJson() { +/// return { +/// 'resultParam': resultParam, +/// }; +/// } +/// } +/// ``` +/// +/// ```dart +/// class SomeCommandExtension extends CommandExtension { +/// @override +/// String get commandKind => 'SomeCommand'; +/// +/// @override +/// Future call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { +/// final SomeCommand someCommand = command as SomeCommand; +/// +/// // Deserialize [Finder]: +/// final Finder finder = finderFactory.createFinder(stubCommand.finder); +/// +/// // Wait for [Element]: +/// handlerFactory.waitForElement(finder); +/// +/// // Alternatively, wait for [Element] absence: +/// handlerFactory.waitForAbsentElement(finder); +/// +/// // Submit known [Command]s: +/// for (int index = 0; i < someCommand.times; index++) { +/// await handlerFactory.handleCommand(Tap(someCommand.finder), prober, finderFactory); +/// } +/// +/// // Alternatively, use [WidgetController]: +/// for (int index = 0; i < stubCommand.times; index++) { +/// await prober.tap(finder); +/// } +/// +/// return const SomeCommandResult('foo bar'); +/// } +/// +/// @override +/// Command deserialize(Map params, DeserializeFinderFactory finderFactory, DeserializeCommandFactory commandFactory) { +/// return SomeCommand.deserialize(params, finderFactory); +/// } +/// } +/// ``` +/// +void enableFlutterDriverExtension({ DataHandler? handler, bool silenceErrors = false, List? finders, List? commands}) { assert(WidgetsBinding.instance == null); - _DriverBinding(handler, silenceErrors, finders ?? []); + _DriverBinding(handler, silenceErrors, finders ?? [], commands ?? []); assert(WidgetsBinding.instance is _DriverBinding); } @@ -150,107 +229,119 @@ typedef CommandHandlerCallback = Future Function(Command c); /// Signature for functions that deserialize a JSON map to a command object. typedef CommandDeserializerCallback = Command Function(Map params); -/// Used to expand the new Finder +/// Used to expand the new [Finder]. abstract class FinderExtension { /// Identifies the type of finder to be used by the driver extension. String get finderType; /// Deserializes the finder from JSON generated by [SerializableFinder.serialize]. - /// [finderFactory] could be used to deserialize nested finders. + /// + /// Use [finderFactory] to deserialize nested [Finder]s. + /// + /// See also: + /// * [Ancestor], a finder that uses other [Finder]s as parameters. SerializableFinder deserialize(Map params, DeserializeFinderFactory finderFactory); /// Signature for functions that run the given finder and return the [Element] /// found, if any, or null otherwise. - /// [finderFactory] could be used to create nested finders. + /// + /// Call [finderFactory] to create known, nested [Finder]s from [SerializableFinder]s. Finder createFinder(SerializableFinder finder, CreateFinderFactory finderFactory); } +/// Used to expand the new [Command]. +/// +/// See also: +/// * [CommandWithTarget], a base class for [Command]s with [Finder]s. +abstract class CommandExtension { + + /// Identifies the type of command to be used by the driver extension. + String get commandKind; + + /// Deserializes the command from JSON generated by [Command.serialize]. + /// + /// Use [finderFactory] to deserialize nested [Finder]s. + /// Usually used for [CommandWithTarget]s. + /// + /// Call [commandFactory] to deserialize commands specified as parameters. + /// + /// See also: + /// * [CommandWithTarget], a base class for commands with target finders. + /// * [Tap], a command that uses [Finder]s as parameter. + Command deserialize(Map params, DeserializeFinderFactory finderFactory, DeserializeCommandFactory commandFactory); + + /// Calls action for given [command]. + /// Returns action [Result]. + /// Invoke [prober] functions to perform widget actions. + /// Use [finderFactory] to create [Finder]s from [SerializableFinder]. + /// Call [handlerFactory] to invoke other [Command]s or [CommandWithTarget]s. + /// + /// The following example shows invoking nested command with [handlerFactory]. + /// + /// ```dart + /// @override + /// Future call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { + /// final StubNestedCommand stubCommand = command as StubNestedCommand; + /// for (int index = 0; i < stubCommand.times; index++) { + /// await handlerFactory.handleCommand(Tap(stubCommand.finder), prober, finderFactory); + /// } + /// return const StubCommandResult('stub response'); + /// } + /// ``` + /// + /// Check the example below for direct [WidgetController] usage with [prober]: + /// + /// ```dart + /// @override + /// Future call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { + /// final StubProberCommand stubCommand = command as StubProberCommand; + /// for (int index = 0; i < stubCommand.times; index++) { + /// await prober.tap(finderFactory.createFinder(stubCommand.finder)); + /// } + /// return const StubCommandResult('stub response'); + /// } + /// ``` + Future call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory); +} + /// The class that manages communication between a Flutter Driver test and the /// application being remote-controlled, on the application side. /// /// This is not normally used directly. It is instantiated automatically when /// calling [enableFlutterDriverExtension]. @visibleForTesting -class FlutterDriverExtension with DeserializeFinderFactory, CreateFinderFactory { +class FlutterDriverExtension with DeserializeFinderFactory, CreateFinderFactory, DeserializeCommandFactory, CommandHandlerFactory { /// Creates an object to manage a Flutter Driver connection. FlutterDriverExtension( this._requestDataHandler, this._silenceErrors, { List finders = const [], + List commands = const [], }) : assert(finders != null) { - _testTextInput.register(); - - _commandHandlers.addAll({ - 'get_health': _getHealth, - 'get_layer_tree': _getLayerTree, - 'get_render_tree': _getRenderTree, - 'enter_text': _enterText, - 'get_text': _getText, - 'request_data': _requestData, - 'scroll': _scroll, - 'scrollIntoView': _scrollIntoView, - 'set_frame_sync': _setFrameSync, - 'set_semantics': _setSemantics, - 'set_text_entry_emulation': _setTextEntryEmulation, - 'tap': _tap, - 'waitFor': _waitFor, - 'waitForAbsent': _waitForAbsent, - 'waitForCondition': _waitForCondition, - 'waitUntilNoTransientCallbacks': _waitUntilNoTransientCallbacks, - 'waitUntilNoPendingFrame': _waitUntilNoPendingFrame, - 'waitUntilFirstFrameRasterized': _waitUntilFirstFrameRasterized, - 'get_semantics_id': _getSemanticsId, - 'get_offset': _getOffset, - 'get_diagnostics_tree': _getDiagnosticsTree, - }); - - _commandDeserializers.addAll({ - 'get_health': (Map params) => GetHealth.deserialize(params), - 'get_layer_tree': (Map params) => GetLayerTree.deserialize(params), - 'get_render_tree': (Map params) => GetRenderTree.deserialize(params), - 'enter_text': (Map params) => EnterText.deserialize(params), - 'get_text': (Map params) => GetText.deserialize(params, this), - 'request_data': (Map params) => RequestData.deserialize(params), - 'scroll': (Map params) => Scroll.deserialize(params, this), - 'scrollIntoView': (Map params) => ScrollIntoView.deserialize(params, this), - 'set_frame_sync': (Map params) => SetFrameSync.deserialize(params), - 'set_semantics': (Map params) => SetSemantics.deserialize(params), - 'set_text_entry_emulation': (Map params) => SetTextEntryEmulation.deserialize(params), - 'tap': (Map params) => Tap.deserialize(params, this), - 'waitFor': (Map params) => WaitFor.deserialize(params, this), - 'waitForAbsent': (Map params) => WaitForAbsent.deserialize(params, this), - 'waitForCondition': (Map params) => WaitForCondition.deserialize(params), - 'waitUntilNoTransientCallbacks': (Map params) => WaitUntilNoTransientCallbacks.deserialize(params), - 'waitUntilNoPendingFrame': (Map params) => WaitUntilNoPendingFrame.deserialize(params), - 'waitUntilFirstFrameRasterized': (Map params) => WaitUntilFirstFrameRasterized.deserialize(params), - 'get_semantics_id': (Map params) => GetSemanticsId.deserialize(params, this), - 'get_offset': (Map params) => GetOffset.deserialize(params, this), - 'get_diagnostics_tree': (Map params) => GetDiagnosticsTree.deserialize(params, this), - }); + registerTextInput(); for(final FinderExtension finder in finders) { _finderExtensions[finder.finderType] = finder; } + + for(final CommandExtension command in commands) { + _commandExtensions[command.commandKind] = command; + } } - final TestTextInput _testTextInput = TestTextInput(); + final WidgetController _prober = LiveWidgetController(WidgetsBinding.instance!); final DataHandler? _requestDataHandler; + final bool _silenceErrors; void _log(String message) { driverLog('FlutterDriverExtension', message); } - final WidgetController _prober = LiveWidgetController(WidgetsBinding.instance!); - final Map _commandHandlers = {}; - final Map _commandDeserializers = {}; final Map _finderExtensions = {}; - - /// With [_frameSync] enabled, Flutter Driver will wait to perform an action - /// until there are no pending frames in the app under test. - bool _frameSync = true; + final Map _commandExtensions = {}; /// Processes a driver command configured by [params] and returns a result /// as an arbitrary JSON object. @@ -266,15 +357,10 @@ class FlutterDriverExtension with DeserializeFinderFactory, CreateFinderFactory Future> call(Map params) async { final String commandKind = params['command']!; try { - final CommandHandlerCallback commandHandler = _commandHandlers[commandKind]!; - final CommandDeserializerCallback commandDeserializer = - _commandDeserializers[commandKind]!; - if (commandHandler == null || commandDeserializer == null) - throw 'Extension $_extensionMethod does not support command $commandKind'; - final Command command = commandDeserializer(params); + final Command command = deserializeCommand(params, this); assert(WidgetsBinding.instance!.isRootWidgetAttached || !command.requiresRootWidgetAttached, 'No root widget is attached; have you remembered to call runApp()?'); - Future responseFuture = commandHandler(command); + Future responseFuture = handleCommand(command, _prober, this); if (command.timeout != null) responseFuture = responseFuture.timeout(command.timeout ?? Duration.zero); final Result? response = await responseFuture; @@ -298,65 +384,6 @@ class FlutterDriverExtension with DeserializeFinderFactory, CreateFinderFactory }; } - Future _getHealth(Command command) async => const Health(HealthStatus.ok); - - Future _getLayerTree(Command command) async { - return LayerTree(RendererBinding.instance?.renderView.debugLayer?.toStringDeep()); - } - - Future _getRenderTree(Command command) async { - return RenderTree(RendererBinding.instance?.renderView.toStringDeep()); - } - - // This can be used to wait for the first frame being rasterized during app launch. - @Deprecated( - 'This method has been deprecated in favor of _waitForCondition. ' - 'This feature was deprecated after v1.9.3.' - ) - Future _waitUntilFirstFrameRasterized(Command command) async { - await WidgetsBinding.instance!.waitUntilFirstFrameRasterized; - return null; - } - - // Waits until at the end of a frame the provided [condition] is [true]. - Future _waitUntilFrame(bool condition(), [ Completer? completer ]) { - completer ??= Completer(); - if (!condition()) { - SchedulerBinding.instance!.addPostFrameCallback((Duration timestamp) { - _waitUntilFrame(condition, completer); - }); - } else { - completer.complete(); - } - return completer.future; - } - - /// Runs `finder` repeatedly until it finds one or more [Element]s. - Future _waitForElement(Finder finder) async { - if (_frameSync) - await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); - - await _waitUntilFrame(() => finder.evaluate().isNotEmpty); - - if (_frameSync) - await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); - - return finder; - } - - /// Runs `finder` repeatedly until it finds zero [Element]s. - Future _waitForAbsentElement(Finder finder) async { - if (_frameSync) - await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); - - await _waitUntilFrame(() => finder.evaluate().isEmpty); - - if (_frameSync) - await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); - - return finder; - } - @override SerializableFinder deserializeFinder(Map json) { final String? finderType = json['finderType']; @@ -369,258 +396,37 @@ class FlutterDriverExtension with DeserializeFinderFactory, CreateFinderFactory @override Finder createFinder(SerializableFinder finder) { - if (_finderExtensions.containsKey(finder.finderType)) { - return _finderExtensions[finder.finderType]!.createFinder(finder, this); + final String finderType = finder.finderType; + if (_finderExtensions.containsKey(finderType)) { + return _finderExtensions[finderType]!.createFinder(finder, this); } return super.createFinder(finder); } - Future _tap(Command command) async { - final Tap tapCommand = command as Tap; - final Finder computedFinder = await _waitForElement( - createFinder(tapCommand.finder).hitTestable() - ); - await _prober.tap(computedFinder); - return const TapResult(); - } - - Future _waitFor(Command command) async { - final WaitFor waitForCommand = command as WaitFor; - await _waitForElement(createFinder(waitForCommand.finder)); - return const WaitForResult(); - } - - Future _waitForAbsent(Command command) async { - final WaitForAbsent waitForAbsentCommand = command as WaitForAbsent; - await _waitForAbsentElement(createFinder(waitForAbsentCommand.finder)); - return const WaitForAbsentResult(); - } - - Future _waitForCondition(Command command) async { - assert(command != null); - final WaitForCondition waitForConditionCommand = command as WaitForCondition; - final WaitCondition condition = deserializeCondition(waitForConditionCommand.condition); - await condition.wait(); - return null; - } - - @Deprecated( - 'This method has been deprecated in favor of _waitForCondition. ' - 'This feature was deprecated after v1.9.3.' - ) - Future _waitUntilNoTransientCallbacks(Command command) async { - if (SchedulerBinding.instance!.transientCallbackCount != 0) - await _waitUntilFrame(() => SchedulerBinding.instance!.transientCallbackCount == 0); - return null; - } - - /// Returns a future that waits until no pending frame is scheduled (frame is synced). - /// - /// Specifically, it checks: - /// * Whether the count of transient callbacks is zero. - /// * Whether there's no pending request for scheduling a new frame. - /// - /// We consider the frame is synced when both conditions are met. - /// - /// This method relies on a Flutter Driver mechanism called "frame sync", - /// which waits for transient animations to finish. Persistent animations will - /// cause this to wait forever. - /// - /// If a test needs to interact with the app while animations are running, it - /// should avoid this method and instead disable the frame sync using - /// `set_frame_sync` method. See [FlutterDriver.runUnsynchronized] for more - /// details on how to do this. Note, disabling frame sync will require the - /// test author to use some other method to avoid flakiness. - /// - /// This method has been deprecated in favor of [_waitForCondition]. - @Deprecated( - 'This method has been deprecated in favor of _waitForCondition. ' - 'This feature was deprecated after v1.9.3.' - ) - Future _waitUntilNoPendingFrame(Command command) async { - await _waitUntilFrame(() { - return SchedulerBinding.instance!.transientCallbackCount == 0 - && !SchedulerBinding.instance!.hasScheduledFrame; - }); - return null; - } - - Future _getSemanticsId(Command command) async { - final GetSemanticsId semanticsCommand = command as GetSemanticsId; - final Finder target = await _waitForElement(createFinder(semanticsCommand.finder)); - final Iterable elements = target.evaluate(); - if (elements.length > 1) { - throw StateError('Found more than one element with the same ID: $elements'); - } - final Element element = elements.single; - RenderObject? renderObject = element.renderObject; - SemanticsNode? node; - while (renderObject != null && node == null) { - node = renderObject.debugSemantics; - renderObject = renderObject.parent as RenderObject?; - } - if (node == null) - throw StateError('No semantics data found'); - return GetSemanticsIdResult(node.id); - } - - Future _getOffset(Command command) async { - final GetOffset getOffsetCommand = command as GetOffset; - final Finder finder = await _waitForElement(createFinder(getOffsetCommand.finder)); - final Element element = finder.evaluate().single; - final RenderBox box = (element.renderObject as RenderBox?)!; - Offset localPoint; - switch (getOffsetCommand.offsetType) { - case OffsetType.topLeft: - localPoint = Offset.zero; - break; - case OffsetType.topRight: - localPoint = box.size.topRight(Offset.zero); - break; - case OffsetType.bottomLeft: - localPoint = box.size.bottomLeft(Offset.zero); - break; - case OffsetType.bottomRight: - localPoint = box.size.bottomRight(Offset.zero); - break; - case OffsetType.center: - localPoint = box.size.center(Offset.zero); - break; - } - final Offset globalPoint = box.localToGlobal(localPoint); - return GetOffsetResult(dx: globalPoint.dx, dy: globalPoint.dy); - } - - Future _getDiagnosticsTree(Command command) async { - final GetDiagnosticsTree diagnosticsCommand = command as GetDiagnosticsTree; - final Finder finder = await _waitForElement(createFinder(diagnosticsCommand.finder)); - final Element element = finder.evaluate().single; - DiagnosticsNode diagnosticsNode; - switch (diagnosticsCommand.diagnosticsType) { - case DiagnosticsType.renderObject: - diagnosticsNode = element.renderObject!.toDiagnosticsNode(); - break; - case DiagnosticsType.widget: - diagnosticsNode = element.toDiagnosticsNode(); - break; - } - return DiagnosticsTreeResult(diagnosticsNode.toJsonMap(DiagnosticsSerializationDelegate( - subtreeDepth: diagnosticsCommand.subtreeDepth, - includeProperties: diagnosticsCommand.includeProperties, - ))); - } - - Future _scroll(Command command) async { - final Scroll scrollCommand = command as Scroll; - final Finder target = await _waitForElement(createFinder(scrollCommand.finder)); - final int totalMoves = scrollCommand.duration.inMicroseconds * scrollCommand.frequency ~/ Duration.microsecondsPerSecond; - final Offset delta = Offset(scrollCommand.dx, scrollCommand.dy) / totalMoves.toDouble(); - final Duration pause = scrollCommand.duration ~/ totalMoves; - final Offset startLocation = _prober.getCenter(target); - Offset currentLocation = startLocation; - final TestPointer pointer = TestPointer(1); - _prober.binding.handlePointerEvent(pointer.down(startLocation)); - await Future.value(); // so that down and move don't happen in the same microtask - for (int moves = 0; moves < totalMoves; moves += 1) { - currentLocation = currentLocation + delta; - _prober.binding.handlePointerEvent(pointer.move(currentLocation)); - await Future.delayed(pause); - } - _prober.binding.handlePointerEvent(pointer.up()); - - return const ScrollResult(); - } - - Future _scrollIntoView(Command command) async { - final ScrollIntoView scrollIntoViewCommand = command as ScrollIntoView; - final Finder target = await _waitForElement(createFinder(scrollIntoViewCommand.finder)); - await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: scrollIntoViewCommand.alignment); - return const ScrollResult(); - } - - Future _getText(Command command) async { - final GetText getTextCommand = command as GetText; - final Finder target = await _waitForElement(createFinder(getTextCommand.finder)); - - final Widget widget = target.evaluate().single.widget; - String? text; - - if (widget.runtimeType == Text) { - text = (widget as Text).data; - } else if (widget.runtimeType == RichText) { - final RichText richText = widget as RichText; - if (richText.text.runtimeType == TextSpan) { - text = (richText.text as TextSpan).text; - } - } else if (widget.runtimeType == TextField) { - text = (widget as TextField).controller?.text; - } else if (widget.runtimeType == TextFormField) { - text = (widget as TextFormField).controller?.text; - } else if (widget.runtimeType == EditableText) { - text = (widget as EditableText).controller.text; + @override + Command deserializeCommand(Map params, DeserializeFinderFactory finderFactory) { + final String? kind = params['command']; + if(_commandExtensions.containsKey(kind)) { + return _commandExtensions[kind]!.deserialize(params, finderFactory, this); } - if (text == null) { - throw UnsupportedError('Type ${widget.runtimeType.toString()} is currently not supported by getText'); + return super.deserializeCommand(params, finderFactory); + } + + @override + @protected + DataHandler? getDataHandler() { + return _requestDataHandler; + } + + @override + Future handleCommand(Command command, WidgetController prober, CreateFinderFactory finderFactory) { + final String kind = command.kind; + if(_commandExtensions.containsKey(kind)) { + return _commandExtensions[kind]!.call(command, prober, finderFactory, this); } - return GetTextResult(text); - } - - Future _setTextEntryEmulation(Command command) async { - final SetTextEntryEmulation setTextEntryEmulationCommand = command as SetTextEntryEmulation; - if (setTextEntryEmulationCommand.enabled) { - _testTextInput.register(); - } else { - _testTextInput.unregister(); - } - return const SetTextEntryEmulationResult(); - } - - Future _enterText(Command command) async { - if (!_testTextInput.isRegistered) { - throw 'Unable to fulfill `FlutterDriver.enterText`. Text emulation is ' - 'disabled. You can enable it using `FlutterDriver.setTextEntryEmulation`.'; - } - final EnterText enterTextCommand = command as EnterText; - _testTextInput.enterText(enterTextCommand.text); - return const EnterTextResult(); - } - - Future _requestData(Command command) async { - final RequestData requestDataCommand = command as RequestData; - return RequestDataResult(_requestDataHandler == null - ? 'No requestData Extension registered' - : await _requestDataHandler!(requestDataCommand.message)); - } - - Future _setFrameSync(Command command) async { - final SetFrameSync setFrameSyncCommand = command as SetFrameSync; - _frameSync = setFrameSyncCommand.enabled; - return const SetFrameSyncResult(); - } - - SemanticsHandle? _semantics; - bool get _semanticsIsEnabled => RendererBinding.instance!.pipelineOwner.semanticsOwner != null; - - Future _setSemantics(Command command) async { - final SetSemantics setSemanticsCommand = command as SetSemantics; - final bool semanticsWasEnabled = _semanticsIsEnabled; - if (setSemanticsCommand.enabled && _semantics == null) { - _semantics = RendererBinding.instance!.pipelineOwner.ensureSemantics(); - if (!semanticsWasEnabled) { - // wait for the first frame where semantics is enabled. - final Completer completer = Completer(); - SchedulerBinding.instance!.addPostFrameCallback((Duration d) { - completer.complete(); - }); - await completer.future; - } - } else if (!setSemanticsCommand.enabled && _semantics != null) { - _semantics!.dispose(); - _semantics = null; - } - return SetSemanticsResult(semanticsWasEnabled != _semanticsIsEnabled); + return super.handleCommand(command, prober, finderFactory); } } diff --git a/packages/flutter_driver/test/src/real_tests/extension_test.dart b/packages/flutter_driver/test/src/real_tests/extension_test.dart index 87d4a25664..b8486147f2 100644 --- a/packages/flutter_driver/test/src/real_tests/extension_test.dart +++ b/packages/flutter_driver/test/src/real_tests/extension_test.dart @@ -20,6 +20,8 @@ import 'package:flutter_driver/src/common/wait.dart'; import 'package:flutter_driver/src/extension/extension.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'stubs/stub_command.dart'; +import 'stubs/stub_command_extension.dart'; import 'stubs/stub_finder.dart'; import 'stubs/stub_finder_extension.dart'; @@ -984,6 +986,100 @@ void main() { }); }); + group('extension commands', () { + int invokes = 0; + final VoidCallback stubCallback = () => invokes++; + + final Widget debugTree = Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Column( + children: [ + FlatButton( + child: const Text('Whatever'), + key: const ValueKey('Button'), + onPressed: stubCallback, + ), + ], + ), + ), + ); + + setUp(() { + invokes = 0; + }); + + testWidgets('unknown extension command', (WidgetTester tester) async { + final FlutterDriverExtension driverExtension = FlutterDriverExtension( + (String arg) async => '', + true, + commands: [], + ); + + Future> invokeCommand(SerializableFinder finder, int times) async { + final Map arguments = StubNestedCommand(finder, times).serialize(); + return await driverExtension.call(arguments); + } + + await tester.pumpWidget(debugTree); + + final Map result = await invokeCommand(ByValueKey('Button'), 10); + expect(result['isError'], true); + expect(result['response'] is String, true); + expect(result['response'] as String, contains('Unsupported command kind StubNestedCommand')); + }); + + testWidgets('nested command', (WidgetTester tester) async { + final FlutterDriverExtension driverExtension = FlutterDriverExtension( + (String arg) async => '', + true, + commands: [ + StubNestedCommandExtension(), + ], + ); + + Future invokeCommand(SerializableFinder finder, int times) async { + await driverExtension.call(const SetFrameSync(false).serialize()); // disable frame sync for test to avoid lock + final Map arguments = StubNestedCommand(finder, times, timeout: const Duration(seconds: 1)).serialize(); + final Map response = await driverExtension.call(arguments); + final Map commandResponse = response['response'] as Map; + return StubCommandResult(commandResponse['resultParam'] as String); + } + + await tester.pumpWidget(debugTree); + + const int times = 10; + final StubCommandResult result = await invokeCommand(ByValueKey('Button'), times); + expect(result.resultParam, 'stub response'); + expect(invokes, times); + }); + + testWidgets('prober command', (WidgetTester tester) async { + final FlutterDriverExtension driverExtension = FlutterDriverExtension( + (String arg) async => '', + true, + commands: [ + StubProberCommandExtension(), + ], + ); + + Future invokeCommand(SerializableFinder finder, int times) async { + await driverExtension.call(const SetFrameSync(false).serialize()); // disable frame sync for test to avoid lock + final Map arguments = StubProberCommand(finder, times, timeout: const Duration(seconds: 1)).serialize(); + final Map response = await driverExtension.call(arguments); + final Map commandResponse = response['response'] as Map; + return StubCommandResult(commandResponse['resultParam'] as String); + } + + await tester.pumpWidget(debugTree); + + const int times = 10; + final StubCommandResult result = await invokeCommand(ByValueKey('Button'), times); + expect(result.resultParam, 'stub response'); + expect(invokes, times); + }); + }); + group('waitUntilFrameSync', () { FlutterDriverExtension driverExtension; Map result; diff --git a/packages/flutter_driver/test/src/real_tests/find_test.dart b/packages/flutter_driver/test/src/real_tests/find_test.dart index df41576849..fccf5b5c93 100644 --- a/packages/flutter_driver/test/src/real_tests/find_test.dart +++ b/packages/flutter_driver/test/src/real_tests/find_test.dart @@ -4,6 +4,7 @@ // @dart = 2.8 +import 'package:flutter_driver/driver_extension.dart'; import 'package:flutter_driver/flutter_driver.dart'; import 'package:flutter_driver/src/common/find.dart'; import 'package:mockito/mockito.dart'; diff --git a/packages/flutter_driver/test/src/real_tests/stubs/stub_command.dart b/packages/flutter_driver/test/src/real_tests/stubs/stub_command.dart new file mode 100644 index 0000000000..779bd15f1b --- /dev/null +++ b/packages/flutter_driver/test/src/real_tests/stubs/stub_command.dart @@ -0,0 +1,58 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_driver/flutter_driver.dart'; + +class StubNestedCommand extends CommandWithTarget { + StubNestedCommand(SerializableFinder finder, this.times, {Duration? timeout}) + : super(finder, timeout: timeout); + + StubNestedCommand.deserialize( + Map json, DeserializeFinderFactory finderFactory) + : times = int.parse(json['times']!), + super.deserialize(json, finderFactory); + + @override + Map serialize() { + return super.serialize()..addAll({'times': '$times'}); + } + + @override + String get kind => 'StubNestedCommand'; + + final int times; +} + +class StubProberCommand extends CommandWithTarget { + StubProberCommand(SerializableFinder finder, this.times, {Duration? timeout}) + : super(finder, timeout: timeout); + + StubProberCommand.deserialize(Map json, DeserializeFinderFactory finderFactory) + : times = int.parse(json['times']!), + super.deserialize(json, finderFactory); + + @override + Map serialize() { + return super.serialize()..addAll({'times': '$times'}); + } + + @override + String get kind => 'StubProberCommand'; + + final int times; +} + +class StubCommandResult extends Result { + const StubCommandResult(this.resultParam); + + final String resultParam; + + @override + Map toJson() { + return { + 'resultParam': resultParam, + }; + } +} diff --git a/packages/flutter_driver/test/src/real_tests/stubs/stub_command_extension.dart b/packages/flutter_driver/test/src/real_tests/stubs/stub_command_extension.dart new file mode 100644 index 0000000000..20d9e645de --- /dev/null +++ b/packages/flutter_driver/test/src/real_tests/stubs/stub_command_extension.dart @@ -0,0 +1,51 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:flutter_driver/src/common/message.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'stub_command.dart'; + +class StubNestedCommandExtension extends CommandExtension { + @override + String get commandKind => 'StubNestedCommand'; + + @override + Future call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { + final StubNestedCommand stubCommand = command as StubNestedCommand; + handlerFactory.waitForElement(finderFactory.createFinder(stubCommand.finder)); + for (int index = 0; index < stubCommand.times; index++) { + await handlerFactory.handleCommand(Tap(stubCommand.finder), prober, finderFactory); + } + return const StubCommandResult('stub response'); + } + + @override + Command deserialize(Map params, DeserializeFinderFactory finderFactory, DeserializeCommandFactory commandFactory) { + return StubNestedCommand.deserialize(params, finderFactory); + } +} + +class StubProberCommandExtension extends CommandExtension { + @override + String get commandKind => 'StubProberCommand'; + + @override + Future call(Command command, WidgetController prober, CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { + final StubProberCommand stubCommand = command as StubProberCommand; + final Finder finder = finderFactory.createFinder(stubCommand.finder); + handlerFactory.waitForElement(finder); + for (int index = 0; index < stubCommand.times; index++) { + await prober.tap(finder); + } + return const StubCommandResult('stub response'); + } + + @override + Command deserialize(Map params, DeserializeFinderFactory finderFactory, DeserializeCommandFactory commandFactory) { + return StubProberCommand.deserialize(params, finderFactory); + } +} diff --git a/packages/flutter_driver/test/src/real_tests/stubs/stub_finder_extension.dart b/packages/flutter_driver/test/src/real_tests/stubs/stub_finder_extension.dart index 6b5694ed5c..b24e5545c4 100644 --- a/packages/flutter_driver/test/src/real_tests/stubs/stub_finder_extension.dart +++ b/packages/flutter_driver/test/src/real_tests/stubs/stub_finder_extension.dart @@ -3,9 +3,9 @@ // found in the LICENSE file. import 'package:flutter/src/widgets/framework.dart'; -import 'package:flutter_driver/driver_extension.dart'; -import 'package:flutter_driver/src/common/create_finder_factory.dart'; import 'package:flutter_test/src/finders.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_driver/src/common/handler_factory.dart'; import 'package:flutter_driver/src/common/find.dart'; import 'stub_finder.dart';