From 55dd3e1a4f1921d6e4fc9bbcb31d70c16b058c37 Mon Sep 17 00:00:00 2001 From: alpaca00 Date: Sat, 2 Nov 2024 21:55:23 +0100 Subject: [PATCH 1/4] feat: Add drag-and-drop functionality - Added documentation to clarify the command's structure and expected behavior --- README.md | 104 +++++++++++++++------------- driver/lib/commands/execute.ts | 52 ++++++++++++++ example/dart/README.md | 49 +++++++++++++ example/dart/extended_commands.dart | 68 ++++++++++++++++++ 4 files changed, 223 insertions(+), 50 deletions(-) create mode 100644 example/dart/README.md create mode 100644 example/dart/extended_commands.dart diff --git a/README.md b/README.md index ab39590e..3f70cb02 100644 --- a/README.md +++ b/README.md @@ -259,56 +259,60 @@ The below _WebDriver example_ is by webdriverio. `flutter:` prefix commands are [`mobile:` command in appium for Android and iOS](https://appium.io/docs/en/latest/guides/execute-methods/). Please replace them properly with your client. -| Flutter API | Status | WebDriver example (JavaScript, webdriverio) | Scope | -| - | - | - | - | -| [FlutterDriver.connectedTo](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/FlutterDriver.connectedTo.html) | :ok: | [`wdio.remote(opts)`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L33) | Session | -| [checkHealth](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/checkHealth.html) | :ok: | `driver.execute('flutter:checkHealth')` | Session | -| clearTextbox | :ok: | `driver.elementClear(find.byType('TextField'))` | Session | -| [clearTimeline](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/clearTimeline.html) | :ok: | `driver.execute('flutter:clearTimeline')` | Session | -| [enterText](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/enterText.html) | :ok: | `driver.elementSendKeys(find.byType('TextField'), 'I can enter text')` (no focus required)
`driver.elementClick(find.byType('TextField')); driver.execute('flutter:enterText', 'I can enter text')` (focus required by tap/click first) | Session | -| [forceGC](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/forceGC.html) | :ok: | `driver.execute('flutter:forceGC')` | Session | -| [getBottomLeft](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getBottomLeft.html) | :ok: | `driver.execute('flutter:getBottomLeft', buttonFinder)` | Widget | -| [getBottomRight](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getBottomRight.html) | :ok: | `driver.execute('flutter:getBottomRight', buttonFinder)` | Widget | -| [getCenter](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getCenter.html) | :ok: | `driver.execute('flutter:getCenter', buttonFinder)` | Widget | -| [getRenderObjectDiagnostics](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getRenderObjectDiagnostics.html) | :ok: | `driver.execute('flutter:getRenderObjectDiagnostics', counterTextFinder, { includeProperties: true, subtreeDepth: 2 })` | Widget | -| [getWidgetDiagnostics](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getWidgetDiagnostics.html) | :ok: (v2.8.0+) | `driver.execute('flutter:getWidgetDiagnostics', counterTextFinder, { includeProperties: true, subtreeDepth: 2 })` | Widget | -| [getRenderTree](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getRenderTree.html) | :ok: | `driver.execute('flutter: getRenderTree')` | Session | -| [getSemanticsId](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getSemanticsId.html) | :ok: | `driver.execute('flutter:getSemanticsId', counterTextFinder)` | Widget | -| [getText](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getText.html) | :ok: | [`driver.getElementText(counterTextFinder)`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L44) | Widget | -| [getTopLeft](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getTopLeft.html) | :ok: | `driver.execute('flutter:getTopLeft', buttonFinder)` | Widget | -| [getTopRight](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getTopRight.html) | :ok: | `driver.execute('flutter:getTopRight', buttonFinder)` | Widget | -| [getVmFlags](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getVmFlags.html) | :x: | | Session | -| [requestData](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html) | :ok: | `driver.execute('flutter:requestData', json.dumps({"deepLink": "myapp://item/id1"}))` | Session | -| [runUnsynchronized](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/runUnsynchronized.html) | :x: | | Session | -| [setFrameSync](https://api.flutter.dev/flutter/flutter_driver/SetFrameSync-class.html) |:ok:| `driver.execute('flutter:setFrameSync', bool , durationMilliseconds)`| Session | -| [screenshot](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/screenshot.html) | :ok: | `driver.takeScreenshot()` | Session | -| [screenshot](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/screenshot.html) | :ok: | `driver.saveScreenshot('a.png')` | Session | -| [scroll](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scroll.html) | :ok: | `driver.execute('flutter:scroll', find.byType('ListView'), {dx: 50, dy: -100, durationMilliseconds: 200, frequency: 30})` | Widget | -| [scrollIntoView](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scrollIntoView.html) | :ok: | `driver.execute('flutter:scrollIntoView', find.byType('TextField'), {alignment: 0.1})`
`driver.execute('flutter:scrollIntoView', find.byType('TextField'), {alignment: 0.1, timeout: 30000})` | Widget | -| [scrollUntilVisible](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scrollUntilVisible.html) | :ok: | `driver.execute('flutter:scrollUntilVisible', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400});`, `driver.execute('flutter:scrollUntilVisible', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400, waitTimeoutMilliseconds: 20000});` | Widget | -| [scrollUntilTapable](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scrollUntilVisible.html) | :ok: | `driver.execute('flutter:scrollUntilTapable', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400});`, `driver.execute('flutter:scrollUntilTapable', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400, waitTimeoutMilliseconds: 20000});` | Widget | -| [setSemantics](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/setSemantics.html) | :x: | | Session | -| [setTextEntryEmulation](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/setTextEntryEmulation.html) | :ok: | `driver.execute('flutter:setTextEntryEmulation', false)` | Session | -| [startTracing](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/startTracing.html) | :x: | | Session | -| [stopTracingAndDownloadTimeline](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/stopTracingAndDownloadTimeline.html) | :x: | | Session | -| [tap](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/tap.html) | :ok: | [`driver.elementClick(buttonFinder)`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L46) | Widget | -| [tap](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/tap.html) | :ok: | [`driver.touchAction({action: 'tap', element: {elementId: buttonFinder}})`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L47) | Widget | -| [tap](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/tap.html) | :ok: | [`driver.execute('flutter:clickElement', buttonFinder, {timeout:5000})`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L47) | Widget | -| [traceAction](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/traceAction.html) | :x: | | Session | -| [waitFor](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitFor.html) | :ok: | `driver.execute('flutter:waitFor', buttonFinder, 100)` | Widget | -| [waitForAbsent](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitForAbsent.html) | :ok: | `driver.execute('flutter:waitForAbsent', buttonFinder)` | Widget | -| [waitForTappable](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitForTappable.html) | :ok: | `driver.execute('flutter:waitForTappable', buttonFinder)` | Widget | -| [waitUntilNoTransientCallbacks](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitUntilNoTransientCallbacks.html) | :x: | | Widget | -| - | :ok: | `driver.execute('flutter:getVMInfo')` | System | -| - | :ok: | `driver.execute('flutter:setIsolateId', 'isolates/2978358234363215')` | System | -| - | :ok: | `driver.execute('flutter:getIsolate', 'isolates/2978358234363215')` or `driver.execute('flutter:getIsolate')` | System | -| :question: | :ok: | `driver.execute('flutter:longTap', find.byValueKey('increment'), {durationMilliseconds: 10000, frequency: 30})` | Widget | -| :question: | :ok: | `driver.execute('flutter:waitForFirstFrame')` | Widget | -| - | :ok: | (Ruby) `driver.execute_script 'flutter:connectObservatoryWsUrl'` | Flutter Driver | -| - | :ok: | (Ruby) `driver.execute_script 'flutter:launchApp', 'bundleId', {arguments: ['arg1'], environment: {ENV1: 'env'}}` | Flutter Driver | - -> **NOTE** -> `flutter:launchApp` launches an app via instrument service. `mobile:activateApp` and `driver.activate_app` are via XCTest API. They are a bit different. +| Flutter API | Status | WebDriver example (JavaScript, webdriverio) | Scope | +|------------------------------------------------------------------------------------------------------------------------------------| - | - |-------------------| +| [FlutterDriver.connectedTo](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/FlutterDriver.connectedTo.html) | :ok: | [`wdio.remote(opts)`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L33) | Session | +| [checkHealth](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/checkHealth.html) | :ok: | `driver.execute('flutter:checkHealth')` | Session | +| clearTextbox | :ok: | `driver.elementClear(find.byType('TextField'))` | Session | +| [clearTimeline](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/clearTimeline.html) | :ok: | `driver.execute('flutter:clearTimeline')` | Session | +| [enterText](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/enterText.html) | :ok: | `driver.elementSendKeys(find.byType('TextField'), 'I can enter text')` (no focus required)
`driver.elementClick(find.byType('TextField')); driver.execute('flutter:enterText', 'I can enter text')` (focus required by tap/click first) | Session | +| [forceGC](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/forceGC.html) | :ok: | `driver.execute('flutter:forceGC')` | Session | +| [getBottomLeft](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getBottomLeft.html) | :ok: | `driver.execute('flutter:getBottomLeft', buttonFinder)` | Widget | +| [getBottomRight](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getBottomRight.html) | :ok: | `driver.execute('flutter:getBottomRight', buttonFinder)` | Widget | +| [getCenter](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getCenter.html) | :ok: | `driver.execute('flutter:getCenter', buttonFinder)` | Widget | +| [getRenderObjectDiagnostics](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getRenderObjectDiagnostics.html) | :ok: | `driver.execute('flutter:getRenderObjectDiagnostics', counterTextFinder, { includeProperties: true, subtreeDepth: 2 })` | Widget | +| [getWidgetDiagnostics](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getWidgetDiagnostics.html) | :ok: (v2.8.0+) | `driver.execute('flutter:getWidgetDiagnostics', counterTextFinder, { includeProperties: true, subtreeDepth: 2 })` | Widget | +| [getRenderTree](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getRenderTree.html) | :ok: | `driver.execute('flutter: getRenderTree')` | Session | +| [getSemanticsId](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getSemanticsId.html) | :ok: | `driver.execute('flutter:getSemanticsId', counterTextFinder)` | Widget | +| [getText](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getText.html) | :ok: | [`driver.getElementText(counterTextFinder)`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L44) | Widget | +| [getTopLeft](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getTopLeft.html) | :ok: | `driver.execute('flutter:getTopLeft', buttonFinder)` | Widget | +| [getTopRight](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getTopRight.html) | :ok: | `driver.execute('flutter:getTopRight', buttonFinder)` | Widget | +| [getVmFlags](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/getVmFlags.html) | :x: | | Session | +| [requestData](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html) | :ok: | `driver.execute('flutter:requestData', json.dumps({"deepLink": "myapp://item/id1"}))` | Session | +| [runUnsynchronized](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/runUnsynchronized.html) | :x: | | Session | +| [setFrameSync](https://api.flutter.dev/flutter/flutter_driver/SetFrameSync-class.html) |:ok:| `driver.execute('flutter:setFrameSync', bool , durationMilliseconds)`| Session | +| [screenshot](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/screenshot.html) | :ok: | `driver.takeScreenshot()` | Session | +| [screenshot](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/screenshot.html) | :ok: | `driver.saveScreenshot('a.png')` | Session | +| [scroll](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scroll.html) | :ok: | `driver.execute('flutter:scroll', find.byType('ListView'), {dx: 50, dy: -100, durationMilliseconds: 200, frequency: 30})` | Widget | +| [scrollIntoView](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scrollIntoView.html) | :ok: | `driver.execute('flutter:scrollIntoView', find.byType('TextField'), {alignment: 0.1})`
`driver.execute('flutter:scrollIntoView', find.byType('TextField'), {alignment: 0.1, timeout: 30000})` | Widget | +| [scrollUntilVisible](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scrollUntilVisible.html) | :ok: | `driver.execute('flutter:scrollUntilVisible', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400});`, `driver.execute('flutter:scrollUntilVisible', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400, waitTimeoutMilliseconds: 20000});` | Widget | +| [scrollUntilTapable](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scrollUntilVisible.html) | :ok: | `driver.execute('flutter:scrollUntilTapable', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400});`, `driver.execute('flutter:scrollUntilTapable', find.byType('ListView'), {item:find.byType('TextField'), dxScroll: 90, dyScroll: -400, waitTimeoutMilliseconds: 20000});` | Widget | +| [setSemantics](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/setSemantics.html) | :x: | | Session | +| [setTextEntryEmulation](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/setTextEntryEmulation.html) | :ok: | `driver.execute('flutter:setTextEntryEmulation', false)` | Session | +| [startTracing](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/startTracing.html) | :x: | | Session | +| [stopTracingAndDownloadTimeline](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/stopTracingAndDownloadTimeline.html) | :x: | | Session | +| [tap](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/tap.html) | :ok: | [`driver.elementClick(buttonFinder)`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L46) | Widget | +| [tap](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/tap.html) | :ok: | [`driver.touchAction({action: 'tap', element: {elementId: buttonFinder}})`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L47) | Widget | +| [tap](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/tap.html) | :ok: | [`driver.execute('flutter:clickElement', buttonFinder, {timeout:5000})`](https://github.com/appium/appium-flutter-driver/blob/5df7386b59bb99008cb4cff262552c7259bb2af2/example/src/index.js#L47) | Widget | +| [traceAction](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/traceAction.html) | :x: | | Session | +| [waitFor](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitFor.html) | :ok: | `driver.execute('flutter:waitFor', buttonFinder, 100)` | Widget | +| [waitForAbsent](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitForAbsent.html) | :ok: | `driver.execute('flutter:waitForAbsent', buttonFinder)` | Widget | +| [waitForTappable](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitForTappable.html) | :ok: | `driver.execute('flutter:waitForTappable', buttonFinder)` | Widget | +| [waitUntilNoTransientCallbacks](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/waitUntilNoTransientCallbacks.html) | :x: | | Widget | +| - | :ok: | `driver.execute('flutter:getVMInfo')` | System | +| - | :ok: | `driver.execute('flutter:setIsolateId', 'isolates/2978358234363215')` | System | +| - | :ok: | `driver.execute('flutter:getIsolate', 'isolates/2978358234363215')` or `driver.execute('flutter:getIsolate')` | System | +| :question: | :ok: | `driver.execute('flutter:longTap', find.byValueKey('increment'), {durationMilliseconds: 10000, frequency: 30})` | Widget | +| :question: | :ok: | `driver.execute('flutter:waitForFirstFrame')` | Widget | +| - | :ok: | (Ruby) `driver.execute_script 'flutter:connectObservatoryWsUrl'` | Flutter Driver | +| - | :ok: | (Ruby) `driver.execute_script 'flutter:launchApp', 'bundleId', {arguments: ['arg1'], environment: {ENV1: 'env'}}` | Flutter Driver | +| dragAndDrop | :ok: | (Python) `driver.execute_script('flutter:commandExtension', payload)` | Command Extension | + +**NOTE** + +- `flutter:launchApp` launches an app via instrument service. `mobile:activateApp` and `driver.activate_app` are via XCTest API. They are a bit different. + +- `flutter:commandExtension` is a command extension to flutter driver, which uses [CommandExtension-class](https://api.flutter.dev/flutter/flutter_driver_extension/CommandExtension-class.html) in the `ext.flutter.driver`, how to use it is [here](example/dart/README.md). ### `isolate` handling #### Change the flutter engine attache to diff --git a/driver/lib/commands/execute.ts b/driver/lib/commands/execute.ts index a4806eb3..6d92e4bb 100644 --- a/driver/lib/commands/execute.ts +++ b/driver/lib/commands/execute.ts @@ -84,6 +84,8 @@ export const execute = async function( return await setFrameSync(this, args[0], args[1]); case `clickElement`: return await clickElement(this, args[0], args[1]); + case `commandExtension`: + return await commandExtension(this, args[0]); default: throw new Error(`Command not support: "${rawCommand}"`); } @@ -218,3 +220,53 @@ const clickElement = async (self:FlutterDriver, elementBase64: string, opts) => timeout }); }; + +const commandExtension = async ( + self: FlutterDriver, + commandPayload: { command: string; [key: string]: any } +) => { + const { command, ...params } = commandPayload; + const commandMapping: { + [key: string]: (self: FlutterDriver, params: any) => Promise + } = { + `dragAndDrop`: dragAndDropCommand, + `commandExtension`: async (self, params) => { + const innerCommand = Object.keys(params)[0]; + const innerParams = params[innerCommand]; + if (commandMapping[innerCommand]) { + return await commandMapping[innerCommand](self, innerParams); + } else { + throw new Error(`Inner command not supported: "${innerCommand}"`); + } + }, +}; + + const commandHandler = commandMapping[command]; + if (commandHandler) { + return await commandHandler(self, params); + } else { + throw new Error(`Command not supported`); + } +}; + +const dragAndDropCommand = async ( + self: FlutterDriver, + params: { + startX: string; + startY: string; + endX: string; + endY: string; + duration: string; + } +) => { + const { startX, startY, endX, endY, duration } = params; + const commandPayload = { + command: `dragAndDrop`, + startX, + startY, + endX, + endY, + duration + }; + return await self.socket!.executeSocketCommand(commandPayload); +}; diff --git a/example/dart/README.md b/example/dart/README.md new file mode 100644 index 00000000..ac7405c7 --- /dev/null +++ b/example/dart/README.md @@ -0,0 +1,49 @@ +#### Flutter Driver Extension + +Copy the [extended_commands.dart](extended_commands.dart) file to the `lib` folder of your Flutter project. + +The entry point must include the `List?` commands argument in either `main.dart` or `test_main.dart` to properly handle the command extension. + +```dart +import 'extended_commands.dart'; + + +void main() { + enableFlutterDriverExtension( + commands: [DragCommandExtension()]); + runApp(const MyApp()); +} +``` + +#### Simple example using `dragAndDrop` command +```python +# python +coord_item_1 = driver.execute_script('flutter:getCenter', item_1) +coord_item_2 = driver.execute_script('flutter:getCenter', item_2) +start_x = coord_item_1['dx'] +start_y = coord_item_1['dy'] +end_y = coord_item_2['dy'] + +params = { + "startX": start_x, + "startY": start_y, + "endX": "0", + "endY": end_y, + "duration": "15000" # minimum duration needed to perform the drag & drop is 15000ms +} + +payload = { + "command": "commandExtension", + "dragAndDrop": params +} + +driver.execute_script("flutter:commandExtension", payload) +``` + +#### Simple app with drag and drop functionality with the `extended_commands.dart` module + +Follow the link: [command-driven-list](https://github.com/Alpaca00/command-driven-list) + +--- + +**Note:** Not recommended to use this functionality in the production environment, due to the potential risk of app crashes and other issues. \ No newline at end of file diff --git a/example/dart/extended_commands.dart b/example/dart/extended_commands.dart new file mode 100644 index 00000000..d2e79555 --- /dev/null +++ b/example/dart/extended_commands.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_driver/src/common/message.dart'; +import 'package:flutter_driver/src/extension/extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + + +class DragCommand extends Command { + final double startX; + final double startY; + final double endX; + final double endY; + final Duration duration; + + DragCommand(this.startX, this.startY, this.endX, this.endY, this.duration); + + @override + String get kind => 'dragAndDrop'; + + DragCommand.deserialize(Map params) + : startX = double.parse(params['startX']!), + startY = double.parse(params['startY']!), + endX = double.parse(params['endX']!), + endY = double.parse(params['endY']!), + duration = Duration(milliseconds: int.parse(params['duration']!)); +} + + +class DragResult extends Result { + final bool success; + + const DragResult(this.success); + + @override + Map toJson() { + return { + 'success': success, + }; + } +} + + +class DragCommandExtension extends CommandExtension { + @override + Future call(Command command, WidgetController prober, + CreateFinderFactory finderFactory, CommandHandlerFactory handlerFactory) async { + final DragCommand dragCommand = command as DragCommand; + + final Offset startLocation = Offset(dragCommand.startX, dragCommand.startY); + final Offset offset = Offset(dragCommand.endX - dragCommand.startX, dragCommand.endY - dragCommand.startY); + + await prober.timedDragFrom(startLocation, offset, dragCommand.duration); + + return const DragResult(true); + } + + @override + String get commandKind => 'dragAndDrop'; + + @override + Command deserialize( + Map params, + DeserializeFinderFactory finderFactory, + DeserializeCommandFactory commandFactory) { + return DragCommand.deserialize(params); + } +} From b6c687be3d2ea0f662e2b24748e7b19884e6037b Mon Sep 17 00:00:00 2001 From: alpaca00 Date: Sun, 3 Nov 2024 18:59:43 +0100 Subject: [PATCH 2/4] Fixed TS syntax errors causing jobs failure --- driver/lib/commands/execute.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/driver/lib/commands/execute.ts b/driver/lib/commands/execute.ts index 6d92e4bb..a0aa74d0 100644 --- a/driver/lib/commands/execute.ts +++ b/driver/lib/commands/execute.ts @@ -229,23 +229,23 @@ const commandExtension = async ( const commandMapping: { [key: string]: (self: FlutterDriver, params: any) => Promise } = { - `dragAndDrop`: dragAndDropCommand, - `commandExtension`: async (self, params) => { - const innerCommand = Object.keys(params)[0]; - const innerParams = params[innerCommand]; - if (commandMapping[innerCommand]) { + 'dragAndDrop': dragAndDropCommand, + 'commandExtension': async (self, params) => { + const innerCommand = Object.keys(params)[0]; + const innerParams = params[innerCommand]; + if (commandMapping[innerCommand]) { return await commandMapping[innerCommand](self, innerParams); - } else { - throw new Error(`Inner command not supported: "${innerCommand}"`); - } - }, -}; + } else { + throw new Error(`Inner command not supported: '${innerCommand}'`); + } + }, + }; const commandHandler = commandMapping[command]; if (commandHandler) { return await commandHandler(self, params); } else { - throw new Error(`Command not supported`); + throw new Error(`Command not supported: '${command}'`); } }; @@ -257,16 +257,16 @@ const dragAndDropCommand = async ( endX: string; endY: string; duration: string; - } + } ) => { const { startX, startY, endX, endY, duration } = params; const commandPayload = { - command: `dragAndDrop`, + command: 'dragAndDrop', startX, startY, endX, endY, - duration - }; - return await self.socket!.executeSocketCommand(commandPayload); + duration, + }; + return await self.socket!.executeSocketCommand(commandPayload); }; From 48ff33b1b52f0d0994f0e817d0c7fd1e27fefc81 Mon Sep 17 00:00:00 2001 From: alpaca00 Date: Sun, 3 Nov 2024 19:02:01 +0100 Subject: [PATCH 3/4] Fixed TS syntax errors causing jobs failure --- driver/lib/commands/execute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/driver/lib/commands/execute.ts b/driver/lib/commands/execute.ts index a0aa74d0..cb9c17d7 100644 --- a/driver/lib/commands/execute.ts +++ b/driver/lib/commands/execute.ts @@ -245,7 +245,7 @@ const commandExtension = async ( if (commandHandler) { return await commandHandler(self, params); } else { - throw new Error(`Command not supported: '${command}'`); + throw new Error(`Command not supported`); } }; From b34e23c08865cd5ab678758d3b3494a4f43be837 Mon Sep 17 00:00:00 2001 From: alpaca00 Date: Mon, 4 Nov 2024 19:49:31 +0100 Subject: [PATCH 4/4] feat: renamed and fixed to direct function call for dragAndDropWithCommandExtension, and moved documentation to the top level --- README.md | 56 ++++++++++++++++++++++++++--- driver/lib/commands/execute.ts | 36 +++---------------- example/dart/README.md | 49 ------------------------- example/dart/extended_commands.dart | 4 +-- 4 files changed, 58 insertions(+), 87 deletions(-) delete mode 100644 example/dart/README.md diff --git a/README.md b/README.md index 3f70cb02..017195c0 100644 --- a/README.md +++ b/README.md @@ -306,13 +306,11 @@ Please replace them properly with your client. | :question: | :ok: | `driver.execute('flutter:waitForFirstFrame')` | Widget | | - | :ok: | (Ruby) `driver.execute_script 'flutter:connectObservatoryWsUrl'` | Flutter Driver | | - | :ok: | (Ruby) `driver.execute_script 'flutter:launchApp', 'bundleId', {arguments: ['arg1'], environment: {ENV1: 'env'}}` | Flutter Driver | -| dragAndDrop | :ok: | (Python) `driver.execute_script('flutter:commandExtension', payload)` | Command Extension | +| dragAndDropWithCommandExtension | :ok: | (Python) `driver.execute_script('flutter:dragAndDropWithCommandExtension', payload)` | Command Extension | **NOTE** +>`flutter:launchApp` launches an app via instrument service. `mobile:activateApp` and `driver.activate_app` are via XCTest API. They are a bit different. -- `flutter:launchApp` launches an app via instrument service. `mobile:activateApp` and `driver.activate_app` are via XCTest API. They are a bit different. - -- `flutter:commandExtension` is a command extension to flutter driver, which uses [CommandExtension-class](https://api.flutter.dev/flutter/flutter_driver_extension/CommandExtension-class.html) in the `ext.flutter.driver`, how to use it is [here](example/dart/README.md). ### `isolate` handling #### Change the flutter engine attache to @@ -351,6 +349,56 @@ These Appium commands can work across context - `getClipboard` - `setClipboard` +## Command Extension (Flutter Driver) + +This is a command extension for Flutter Driver, utilizing the [CommandExtension-class](https://api.flutter.dev/flutter/flutter_driver_extension/CommandExtension-class.html) within `ext.flutter.driver` + +Available commands: + +- `dragAndDropWithCommandExtension` – performs a drag-and-drop action on the screen by specifying the start and end coordinates and the action duration. + +### How to use + +Copy the [extended_commands.dart](extended_commands.dart) file to the `lib` folder of your Flutter project. + +The entry point must include the `List?` commands argument in either `main.dart` or `test_main.dart` to properly handle the command extension. + + +```dart +import 'extended_commands.dart'; + + +void main() { + enableFlutterDriverExtension( + commands: [DragCommandExtension()]); + runApp(const MyApp()); +} +``` + +#### Simple example using `dragAndDropWithCommandExtension` command in Python + +```python +# python +coord_item_1 = driver.execute_script("flutter:getCenter", item_1) +coord_item_2 = driver.execute_script("flutter:getCenter", item_2) +start_x = coord_item_1["dx"] +start_y = coord_item_1["dy"] +end_y = coord_item_2["dy"] + +payload = { + "startX": start_x, + "startY": start_y, + "endX": "0", + "endY": end_y, + "duration": "15000" # minimum 15000ms needed to drag n drop +} + +driver.execute_script("flutter:dragAndDropWithCommandExtension", payload) +``` + +For debugging or testing in other programming languages, you can use the APK available in this [repository](https://github.com/Alpaca00/command-driven-list) or build an IPA. + + ## Troubleshooting - Input texts https://github.com/appium/appium-flutter-driver/issues/417 diff --git a/driver/lib/commands/execute.ts b/driver/lib/commands/execute.ts index cb9c17d7..6e2cfbfa 100644 --- a/driver/lib/commands/execute.ts +++ b/driver/lib/commands/execute.ts @@ -84,8 +84,8 @@ export const execute = async function( return await setFrameSync(this, args[0], args[1]); case `clickElement`: return await clickElement(this, args[0], args[1]); - case `commandExtension`: - return await commandExtension(this, args[0]); + case `dragAndDropWithCommandExtension`: + return await dragAndDropWithCommandExtension(this, args[0]); default: throw new Error(`Command not support: "${rawCommand}"`); } @@ -221,35 +221,7 @@ const clickElement = async (self:FlutterDriver, elementBase64: string, opts) => }); }; -const commandExtension = async ( - self: FlutterDriver, - commandPayload: { command: string; [key: string]: any } -) => { - const { command, ...params } = commandPayload; - const commandMapping: { - [key: string]: (self: FlutterDriver, params: any) => Promise - } = { - 'dragAndDrop': dragAndDropCommand, - 'commandExtension': async (self, params) => { - const innerCommand = Object.keys(params)[0]; - const innerParams = params[innerCommand]; - if (commandMapping[innerCommand]) { - return await commandMapping[innerCommand](self, innerParams); - } else { - throw new Error(`Inner command not supported: '${innerCommand}'`); - } - }, - }; - - const commandHandler = commandMapping[command]; - if (commandHandler) { - return await commandHandler(self, params); - } else { - throw new Error(`Command not supported`); - } -}; - -const dragAndDropCommand = async ( +const dragAndDropWithCommandExtension = async ( self: FlutterDriver, params: { startX: string; @@ -261,7 +233,7 @@ const dragAndDropCommand = async ( ) => { const { startX, startY, endX, endY, duration } = params; const commandPayload = { - command: 'dragAndDrop', + command: 'dragAndDropWithCommandExtension', startX, startY, endX, diff --git a/example/dart/README.md b/example/dart/README.md deleted file mode 100644 index ac7405c7..00000000 --- a/example/dart/README.md +++ /dev/null @@ -1,49 +0,0 @@ -#### Flutter Driver Extension - -Copy the [extended_commands.dart](extended_commands.dart) file to the `lib` folder of your Flutter project. - -The entry point must include the `List?` commands argument in either `main.dart` or `test_main.dart` to properly handle the command extension. - -```dart -import 'extended_commands.dart'; - - -void main() { - enableFlutterDriverExtension( - commands: [DragCommandExtension()]); - runApp(const MyApp()); -} -``` - -#### Simple example using `dragAndDrop` command -```python -# python -coord_item_1 = driver.execute_script('flutter:getCenter', item_1) -coord_item_2 = driver.execute_script('flutter:getCenter', item_2) -start_x = coord_item_1['dx'] -start_y = coord_item_1['dy'] -end_y = coord_item_2['dy'] - -params = { - "startX": start_x, - "startY": start_y, - "endX": "0", - "endY": end_y, - "duration": "15000" # minimum duration needed to perform the drag & drop is 15000ms -} - -payload = { - "command": "commandExtension", - "dragAndDrop": params -} - -driver.execute_script("flutter:commandExtension", payload) -``` - -#### Simple app with drag and drop functionality with the `extended_commands.dart` module - -Follow the link: [command-driven-list](https://github.com/Alpaca00/command-driven-list) - ---- - -**Note:** Not recommended to use this functionality in the production environment, due to the potential risk of app crashes and other issues. \ No newline at end of file diff --git a/example/dart/extended_commands.dart b/example/dart/extended_commands.dart index d2e79555..c38aa524 100644 --- a/example/dart/extended_commands.dart +++ b/example/dart/extended_commands.dart @@ -16,7 +16,7 @@ class DragCommand extends Command { DragCommand(this.startX, this.startY, this.endX, this.endY, this.duration); @override - String get kind => 'dragAndDrop'; + String get kind => 'dragAndDropWithCommandExtension'; DragCommand.deserialize(Map params) : startX = double.parse(params['startX']!), @@ -56,7 +56,7 @@ class DragCommandExtension extends CommandExtension { } @override - String get commandKind => 'dragAndDrop'; + String get commandKind => 'dragAndDropWithCommandExtension'; @override Command deserialize(