From 5d2ca84ef03a989d78dcb20e66cdc8c904e60407 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Thu, 19 Sep 2024 20:46:51 +0200 Subject: [PATCH 01/21] feat: add flutter integration driver commands and tests --- .github/workflows/functional-test.yml | 135 ++++++++++++++ appium/options/flutter/__init__.py | 1 + appium/options/flutter/base.py | 40 +++++ .../flutter_element_wait_timeout_option.py | 33 ++++ .../flutter_enable_mock_camera_option.py | 33 ++++ .../flutter_server_launch_timeout_option.py | 33 ++++ .../flutter/flutter_system_port_option.py | 33 ++++ appium/webdriver/common/flutterby.py | 25 +++ .../extensions/flutter/flutter_commands.py | 166 ++++++++++++++++++ appium/webdriver/flutter_finder.py | 51 ++++++ test/functional/flutter/__init__.py | 0 test/functional/flutter/commands_test.py | 133 ++++++++++++++ test/functional/flutter/file/second_qr.png | Bin 0 -> 132652 bytes test/functional/flutter/file/success_qr.png | Bin 0 -> 32757 bytes test/functional/flutter/finder_test.py | 56 ++++++ test/functional/flutter/helper/__init__.py | 0 .../flutter/helper/desired_capabilities.py | 40 +++++ test/functional/flutter/helper/test_helper.py | 55 ++++++ 18 files changed, 834 insertions(+) create mode 100644 appium/options/flutter/__init__.py create mode 100644 appium/options/flutter/base.py create mode 100644 appium/options/flutter/flutter_element_wait_timeout_option.py create mode 100644 appium/options/flutter/flutter_enable_mock_camera_option.py create mode 100644 appium/options/flutter/flutter_server_launch_timeout_option.py create mode 100644 appium/options/flutter/flutter_system_port_option.py create mode 100644 appium/webdriver/common/flutterby.py create mode 100644 appium/webdriver/extensions/flutter/flutter_commands.py create mode 100644 appium/webdriver/flutter_finder.py create mode 100644 test/functional/flutter/__init__.py create mode 100644 test/functional/flutter/commands_test.py create mode 100644 test/functional/flutter/file/second_qr.png create mode 100644 test/functional/flutter/file/success_qr.png create mode 100644 test/functional/flutter/finder_test.py create mode 100644 test/functional/flutter/helper/__init__.py create mode 100644 test/functional/flutter/helper/desired_capabilities.py create mode 100644 test/functional/flutter/helper/test_helper.py diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index b7221caa..96ebae38 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -196,3 +196,138 @@ jobs: with: name: appium-android-${{matrix.test_targets.name}}.log path: appium.log + + flutter_e2e_test: + strategy: + fail-fast: false + matrix: + include: + - platform: macos-14 + e2e-tests: flutter-ios + - platform: ubuntu-latest + e2e-tests: flutter-android + + runs-on: ${{ matrix.platform }} + + env: + API_LEVEL: 29 + ARCH: x86 + CI: true + XCODE_VERSION: 15.4 + IOS_VERSION: 17.5 + IPHONE_MODEL: iPhone 15 + FLUTTER_ANDROID_APP: "https://github.com/AppiumTestDistribution/appium-flutter-server/releases/latest/download/app-debug.apk" + FLUTTER_IOS_APP: "https://github.com/AppiumTestDistribution/appium-flutter-server/releases/latest/download/ios.zip" + + steps: + + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + if: matrix.e2e-tests == 'flutter-android' + with: + distribution: 'zulu' + java-version: '17' + + - name: Enable KVM group perms + if: matrix.e2e-tests == 'flutter-android' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: AVD cache + if: matrix.e2e-tests == 'flutter-android' + uses: actions/cache@v3 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ env.API_LEVEL }} + + - name: Create AVD and generate snapshot for caching + if: matrix.e2e-tests == 'flutter-android' && steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ env.API_LEVEL }} + arch: ${{ env.ARCH }} + target: google_apis + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: 3.12 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + + - name: Install Appium + run: npm install --location=global appium + + - name: Install Android drivers and Run Appium + if: matrix.e2e-tests == 'flutter-android' + run: | + appium driver install uiautomator2 + appium driver install appium-flutter-integration-driver --source npm + nohup appium --allow-insecure=adb_shell --relaxed-security --log-timestamp --log-no-colors 2>&1 > appium_android.log & + + - name: Run Android tests + if: matrix.e2e-tests == 'flutter-android' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ env.API_LEVEL }} + arch: ${{ env.ARCH }} + script: | + pip install --upgrade pip + pip install --upgrade pipenv + pipenv lock --clear + pipenv install -d --system + export PLATFORM=android + pytest test/functional/flutter/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html + target: google_apis + profile: Nexus 5X + disable-spellchecker: true + disable-animations: true + + - name: Select Xcode + if: matrix.e2e-tests == 'flutter-ios' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - run: defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false + + - uses: futureware-tech/simulator-action@v3 + with: + # https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md + model: ${{ env.IPHONE_MODEL }} + os_version: ${{ env.IOS_VERSION }} + + - name: install dependencies + if: matrix.e2e-tests == 'flutter-ios' + run: brew install ffmpeg + + - name: Install IOS drivers and Run Appium + if: matrix.e2e-tests == 'flutter-ios' + run: | + appium driver install xcuitest + appium driver install appium-flutter-integration-driver --source npm + appium driver run xcuitest build-wda + nohup appium --allow-insecure=adb_shell --relaxed-security --log-timestamp --log-no-colors 2>&1 > appium_ios.log & + + - name: Run IOS tests + if: matrix.e2e-tests == 'flutter-ios' + run: | + # Separate 'run' creates differnet pipenv env. Does them in one run for now. + pip install --upgrade pip + pip install --upgrade pipenv + pipenv lock --clear + pipenv install -d --system + export PLATFORM=ios + pytest test/functional/flutter/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html diff --git a/appium/options/flutter/__init__.py b/appium/options/flutter/__init__.py new file mode 100644 index 00000000..3e9808cd --- /dev/null +++ b/appium/options/flutter/__init__.py @@ -0,0 +1 @@ +from .base import FlutterOptions \ No newline at end of file diff --git a/appium/options/flutter/base.py b/appium/options/flutter/base.py new file mode 100644 index 00000000..98500b64 --- /dev/null +++ b/appium/options/flutter/base.py @@ -0,0 +1,40 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Dict +from appium.options.common.automation_name_option import AUTOMATION_NAME +from appium.options.common.base import AppiumOptions +from appium.options.flutter.flutter_element_wait_timeout_option import FlutterElementWaitTimeOutOption +from appium.options.flutter.flutter_enable_mock_camera_option import FlutterEnableMockCameraOption +from appium.options.flutter.flutter_server_launch_timeout_option import FlutterServerLaunchTimeOutOption +from appium.options.flutter.flutter_system_port_option import FlutterSystemPortOption + + +class FlutterOptions( + AppiumOptions, + FlutterElementWaitTimeOutOption, + FlutterEnableMockCameraOption, + FlutterServerLaunchTimeOutOption, + FlutterSystemPortOption +): + + @property + def default_capabilities(self) -> Dict: + return { + AUTOMATION_NAME: 'FlutterIntegration', + } + \ No newline at end of file diff --git a/appium/options/flutter/flutter_element_wait_timeout_option.py b/appium/options/flutter/flutter_element_wait_timeout_option.py new file mode 100644 index 00000000..5cfe2a92 --- /dev/null +++ b/appium/options/flutter/flutter_element_wait_timeout_option.py @@ -0,0 +1,33 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Optional +from appium.options.common.supports_capabilities import SupportsCapabilities + + +FLUTTER_ELEMENT_WAIT_TIMEOUT= 'flutterElementWaitTimeout' + + +class FlutterElementWaitTimeOutOption(SupportsCapabilities): + + @property + def flutter_element_wait_timeout(self) -> Optional[int]: + return self.get_capability(FLUTTER_ELEMENT_WAIT_TIMEOUT) + + @flutter_element_wait_timeout.setter + def flutter_element_wait_timeout(self, time_in_millis: int) -> None: + self.set_capability(FLUTTER_ELEMENT_WAIT_TIMEOUT, time_in_millis) \ No newline at end of file diff --git a/appium/options/flutter/flutter_enable_mock_camera_option.py b/appium/options/flutter/flutter_enable_mock_camera_option.py new file mode 100644 index 00000000..9ce092d4 --- /dev/null +++ b/appium/options/flutter/flutter_enable_mock_camera_option.py @@ -0,0 +1,33 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Optional +from appium.options.common.supports_capabilities import SupportsCapabilities + + +FLUTTER_ENABLE_MOCK_CAMERA = 'flutterEnableMockCamera' + + +class FlutterEnableMockCameraOption(SupportsCapabilities): + + @property + def flutter_enable_mock_camera(self) -> Optional[int]: + return self.get_capability(FLUTTER_ENABLE_MOCK_CAMERA) + + @flutter_enable_mock_camera.setter + def flutter_enable_mock_camera(self, value: bool) -> None: + self.set_capability(FLUTTER_ENABLE_MOCK_CAMERA, value) \ No newline at end of file diff --git a/appium/options/flutter/flutter_server_launch_timeout_option.py b/appium/options/flutter/flutter_server_launch_timeout_option.py new file mode 100644 index 00000000..fcde2ea1 --- /dev/null +++ b/appium/options/flutter/flutter_server_launch_timeout_option.py @@ -0,0 +1,33 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Optional +from appium.options.common.supports_capabilities import SupportsCapabilities + + +FLUTTER_SERVER_LAUNCH_TIMEOUT= 'flutterServerLaunchTimeout' + + +class FlutterServerLaunchTimeOutOption(SupportsCapabilities): + + @property + def flutter_server_launch_timeout(self) -> Optional[int]: + return self.get_capability(FLUTTER_SERVER_LAUNCH_TIMEOUT) + + @flutter_server_launch_timeout.setter + def flutter_server_launch_timeout(self, time_in_millis: int) -> None: + self.set_capability(FLUTTER_SERVER_LAUNCH_TIMEOUT, time_in_millis) \ No newline at end of file diff --git a/appium/options/flutter/flutter_system_port_option.py b/appium/options/flutter/flutter_system_port_option.py new file mode 100644 index 00000000..6b0c9849 --- /dev/null +++ b/appium/options/flutter/flutter_system_port_option.py @@ -0,0 +1,33 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Optional +from appium.options.common.supports_capabilities import SupportsCapabilities + + +FLUTTER_SYSTEM_PORT = 'flutterSystemPort' + + +class FlutterSystemPortOption(SupportsCapabilities): + + @property + def flutter_system_port(self) -> Optional[int]: + return self.get_capability(FLUTTER_SYSTEM_PORT) + + @flutter_system_port.setter + def flutter_system_port(self, value: int) -> None: + self.set_capability(FLUTTER_SYSTEM_PORT, value) \ No newline at end of file diff --git a/appium/webdriver/common/flutterby.py b/appium/webdriver/common/flutterby.py new file mode 100644 index 00000000..51c98c34 --- /dev/null +++ b/appium/webdriver/common/flutterby.py @@ -0,0 +1,25 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from selenium.webdriver.common.by import By + +class FlutterBy(By): + FLUTTER_SEMANTICS_LABEL = '-flutter semantics label' + FLUTTER_TYPE = '-flutter type' + FLUTTER_KEY = '-flutter key' + FLUTTER_TEXT = '-flutter text' + FLUTTER_TEXT_CONTAINING = '-flutter text containing' diff --git a/appium/webdriver/extensions/flutter/flutter_commands.py b/appium/webdriver/extensions/flutter/flutter_commands.py new file mode 100644 index 00000000..62d555c3 --- /dev/null +++ b/appium/webdriver/extensions/flutter/flutter_commands.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +from typing import Any, Optional, Tuple, Union +from appium.webdriver.flutter_finder import FlutterFinder +from appium.webdriver.webdriver import WebDriver +from appium.webdriver.webelement import WebElement + + +class FlutterCommand(): + + def __init__(self, driver: WebDriver) -> None: + self.driver = driver + + # wait commands + + def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[int] = None) -> None: + """ + Waits for a element to become visible. + + Args: + locator: The element to wait for; can be a WebElement or a FlutterFinder. + time_out: Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + + Returns: + None: + """ + if isinstance(locator, WebElement): + opts = {'element': locator, 'timeout': time_out} + else: + opts = {'locator': locator.to_dict(), 'timeout': time_out} + + self.execute_flutter_command('waitForVisible', opts) + + + def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[int] = None) -> None: + """ + Waits for a element to become invisible. + + Args: + locator: The element to wait for; can be a WebElement or a FlutterFinder. + time_out: Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + + Returns: + None: + """ + if isinstance(locator, WebElement): + opts = {'element': locator, 'timeout': time_out} + else: + opts = {'locator': locator.to_dict(), 'timeout': time_out} + + self.execute_flutter_command('waitForAbsent', opts) + + # flutter action commands + + def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: + """ + Performs a double-click on the given element, with an optional offset. + + Args: + element: The element to double-click on. This parameter is required. + offset: The x and y offsets from the element to click at. If not specified, the click is performed at the element's center. + + Returns: + None: + """ + opts = {'origin': element} + if offset: + opts['offset'] = {'x': offset[0], 'y': offset[1]} + self.execute_flutter_command('doubleClick', opts) + + def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: + """ + Performs a long press on the given element, with an optional offset. + + Args: + element: The element to perform the long press on. This parameter is required. + offset: The x and y offsets from the element to perform the long press at. If not specified, the long press is performed at the element's center. + + Returns: + None: + """ + opts = {'origin': element} + if offset: + opts['offset'] = {'x': offset[0], 'y': offset[1]} + self.execute_flutter_command('longPress', opts) + + def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: + """ + Performs a drag-and-drop operation from a source element to a target element. + + Args: + source: The element to drag from. + target: The element to drop onto. + + Returns: + None: + """ + self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) + + def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: Optional[str] = 'down', **opts: Any) -> WebElement: + """ + Scrolls until the specified element becomes visible. + + Args: + scroll_to: The Flutter element to scroll to. + scroll_direction: The direction to scroll up/down. Defaults to 'down'. + + KeywordArgs: + scrollView (str): The view of the scroll. + delta (int): delta for the scroll + maxScrolls (int): Max times to scroll + settleBetweenScrollsTimeout (int): settle timeout + dragDuration (int): time gap between each scroll + + Returns: + Webelement: scrolled element + """ + opts['finder'] = scroll_to.to_dict() + opts['scrollDirection'] = scroll_direction + return self.execute_flutter_command('scrollTillVisible', opts) + + def inject_mock_image(self, value: str) -> str: + """ + Injects a mock image to the device. The input can be a file path or a base64-encoded string. + + Args: + value: The file path of the image or a base64-encoded string. + + Returns: + str: Image ID of the injected image. + """ + import os + if os.path.isfile(value): + with open(value, "rb") as image_file: + base64_encoded_image = base64.b64encode(image_file.read()).decode('utf-8') + else: + base64_encoded_image = value + return self.execute_flutter_command('injectImage', {'base64Image': base64_encoded_image}) + + def activate_injected_image(self, image_id: str) -> None: + """ + Activates an injected image with image ID. + + Args: + image_id: The ID of the injected image to activate. + + Returns: + None: + """ + self.execute_flutter_command("activateInjectedImage", {'imageId': image_id}) + + def execute_flutter_command(self, scriptName: str, params: dict) -> Any: + return self.driver.execute_script(f'flutter: {scriptName}', params) \ No newline at end of file diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/flutter_finder.py new file mode 100644 index 00000000..b7a77759 --- /dev/null +++ b/appium/webdriver/flutter_finder.py @@ -0,0 +1,51 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from appium.webdriver.common.flutterby import FlutterBy + +class FlutterFinder(): + + def __init__(self, using: str, value: str) -> None: + self.using = using + self.value = value + + @staticmethod + def by_flutter_key(value: str) -> 'FlutterFinder': + return FlutterFinder(FlutterBy.FLUTTER_KEY, value) + + @staticmethod + def by_flutter_text(value: str) -> 'FlutterFinder': + return FlutterFinder(FlutterBy.FLUTTER_TEXT, value) + + @staticmethod + def by_flutter_semantics_label(value: str) -> 'FlutterFinder': + return FlutterFinder(FlutterBy.FLUTTER_SEMANTICS_LABEL, value) + + @staticmethod + def by_flutter_type(value: str) -> 'FlutterFinder': + return FlutterFinder(FlutterBy.FLUTTER_TYPE, value) + + @staticmethod + def by_flutter_text_containing(value: str) -> 'FlutterFinder': + return FlutterFinder(FlutterBy.FLUTTER_TEXT_CONTAINING, value) + + def to_dict(self): + return { "using": self.using, "value": self.value} + + def as_strings(self) -> str: + return self.using, self.value + diff --git a/test/functional/flutter/__init__.py b/test/functional/flutter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/functional/flutter/commands_test.py b/test/functional/flutter/commands_test.py new file mode 100644 index 00000000..6a79f4c4 --- /dev/null +++ b/test/functional/flutter/commands_test.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import pytest + +from appium.webdriver.common.flutterby import FlutterBy +from appium.webdriver.flutter_finder import FlutterFinder +from test.functional.flutter.helper.test_helper import BaseTestCase + +class TestFlutterCommands(BaseTestCase): + + def test_wait_command(self): + self.__open_screen('Lazy Loading') + + message_field_finder = FlutterFinder.by_flutter_key('message_field') + toggle_button_finder = FlutterFinder.by_flutter_key('toggle_button') + + message_field = self.driver.find_element(*message_field_finder.as_strings()) + toggle_button = self.driver.find_element(*toggle_button_finder.as_strings()) + assert message_field.is_displayed() == True + assert message_field.text == 'Hello world' + + toggle_button.click() + self.flutter_command.wait_for_invisible(message_field_finder) + assert len(self.driver.find_elements(*message_field_finder.as_strings())) == 0 + + toggle_button.click() + self.flutter_command.wait_for_visible(message_field) + assert len(self.driver.find_elements(*message_field_finder.as_strings())) == 1 + + @pytest.mark.skip + def test_scroll_till_visible_command(self): + self.__open_screen('Vertical Swiping') + + java_text_finder = FlutterFinder.by_flutter_text("Java") + protractor_text_finder = FlutterFinder.by_flutter_text("Protractor") + + first_element = self.flutter_command.scroll_till_visible(java_text_finder) + assert first_element.get_attribute('displayed') == 'true' + + second_element = self.flutter_command.scroll_till_visible(protractor_text_finder) + assert second_element.get_attribute('displayed') == 'true' + assert first_element.get_attribute('displayed') == 'false' + + first_element = self.flutter_command.scroll_till_visible(java_text_finder, scroll_direction='up') + assert second_element.get_attribute('displayed') == 'false' + assert first_element.get_attribute('displayed') == 'true' + + @pytest.mark.skip + def test_scroll_till_visible_with_scroll_params_command(self): + self.__open_screen('Vertical Swiping') + + scroll_params = {'scrollView': FlutterFinder.by_flutter_type("Scrollable").to_dict(), + 'delta': 30, + 'maxScrolls': 30, + 'settleBetweenScrollsTimeout': 5000, + 'dragDuration': 35 + } + first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text("Playwright"), **scroll_params) + assert first_element.get_attribute('displayed') == 'true' + + @pytest.mark.skip + def test_double_click_command(self): + self.__open_screen('Double Tap') + + double_tap_button = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'double_tap_button').find_element(FlutterBy.FLUTTER_TEXT, 'Double Tap') + assert double_tap_button.text == 'Double Tap' + + self.flutter_command.perform_double_click(double_tap_button) + assert self.driver.find_element(FlutterBy.FLUTTER_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' + + self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'Ok').click() + self.flutter_command.perform_double_click(double_tap_button, (10,2)) + assert self.driver.find_element(FlutterBy.FLUTTER_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' + + self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'Ok').click() + + @pytest.mark.skip + def test_long_press_command(self): + self.__open_screen('Long Press') + + long_press_button = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'long_press_button') + self.flutter_command.perform_long_press(long_press_button) + + success_pop_up = self.driver.find_element(FlutterBy.FLUTTER_TEXT,'It was a long press') + assert success_pop_up.text == 'It was a long press' + assert success_pop_up.is_displayed() == True + + @pytest.mark.skip + def test_drag_and_drop_command(self): + self.__open_screen('Drag & Drop') + + drag_element = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'drag_me') + drop_element = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'drop_zone') + self.flutter_command.perform_drag_and_drop(drag_element, drop_element) + assert self.driver.find_element(FlutterBy.FLUTTER_TEXT,'The box is dropped').is_displayed() == True + + @pytest.mark.skip + def test_camera_mocking(self): + self.__open_screen('Image Picker') + + success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') + second_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'second_qr.png') + + image_id = self.flutter_command.inject_mock_image(success_qr_file_path) + self.flutter_command.inject_mock_image(second_qr_file_path) + self.driver.find_element(FlutterBy.FLUTTER_KEY, 'capture_image').click() + self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'PICK').click() + assert self.driver.find_element(FlutterBy.FLUTTER_TEXT,'SecondInjectedImage').is_displayed() == True + + self.flutter_command.activate_injected_image(image_id) + self.driver.find_element(FlutterBy.FLUTTER_KEY, 'capture_image').click() + self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'PICK').click() + assert self.driver.find_element(FlutterBy.FLUTTER_TEXT,'Success!').is_displayed() == True + + def __open_screen(self, screen_name: str) -> None: + self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'Login').click() + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text(screen_name)) + element.click() + diff --git a/test/functional/flutter/file/second_qr.png b/test/functional/flutter/file/second_qr.png new file mode 100644 index 0000000000000000000000000000000000000000..355548c30e2066dccf7f2cbf5e990eff59801677 GIT binary patch literal 132652 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 e|N7x2c$sT16K9I5mue}_+y**5hr(AVh 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 S^YP!2q$&#P^0n{GLjDhG!;-iF literal 0 HcmV?d00001 diff --git a/test/functional/flutter/finder_test.py b/test/functional/flutter/finder_test.py new file mode 100644 index 00000000..a1e60a87 --- /dev/null +++ b/test/functional/flutter/finder_test.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from appium.webdriver.common.flutterby import FlutterBy +from appium.webdriver.flutter_finder import FlutterFinder +from test.functional.flutter.helper.test_helper import BaseTestCase + +LOGIN_BUTTON_FINDER = FlutterFinder.by_flutter_text('Login') + +class TestFlutterFinders(BaseTestCase): + + def test_by_flutter_key(self): + user_name_field_finder = FlutterFinder.by_flutter_key('username_text_field') + user_name_field = self.driver.find_element(*user_name_field_finder.as_strings()) + assert user_name_field.text == 'admin' + + user_name_field.clear() + user_name_field = self.driver.find_element(*user_name_field_finder.as_strings()).send_keys('admin123') + assert user_name_field.text == 'admin123' + + def test_by_flutter_type(self): + login_button = self.driver.find_element(FlutterBy.FLUTTER_TYPE,'ElevatedButton') + assert login_button.find_element(FlutterBy.FLUTTER_TYPE, 'Text').text == 'Login' + + def test_by_flutter_text(self): + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_strings()) + assert login_button.text == 'Login' + + login_button.click() + slider = self.driver.find_elements(FlutterBy.FLUTTER_TEXT, 'Slider') + assert len(slider) == 1 + + def test_by_flutter_text_containing(self): + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_strings()) + login_button.click() + vertical_swipe_label = self.driver.find_element(FlutterBy.FLUTTER_TEXT_CONTAINING, 'Vertical') + assert vertical_swipe_label.text == 'Vertical Swiping' + + def test_by_flutter_semantics_label(self): + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_strings()) + login_button.click() + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Lazy Loading')) + element.click() + message_field = self.driver.find_element(FlutterBy.FLUTTER_SEMANTICS_LABEL, 'message_field') + assert message_field.text == 'Hello world' \ No newline at end of file diff --git a/test/functional/flutter/helper/__init__.py b/test/functional/flutter/helper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/functional/flutter/helper/desired_capabilities.py b/test/functional/flutter/helper/desired_capabilities.py new file mode 100644 index 00000000..bc621b64 --- /dev/null +++ b/test/functional/flutter/helper/desired_capabilities.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from typing import Any, Dict + +def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: + if platform_name == 'android': + desired_caps: Dict[str, Any] = { + 'platformName': 'Android', + 'deviceName': 'Android Emulator', + 'newCommandTimeout': 240, + 'uiautomator2ServerInstallTimeout': 120000, + 'adbExecTimeout': 120000, + 'app': os.getenv('FLUTTER_ANDROID_APP') + } + else: + desired_caps: Dict[str, Any] = { + 'deviceName': os.getenv('IPHONE_MODEL'), + 'platformName': 'iOS', + 'platformVersion': os.getenv('IOS_VERSION'), + 'allowTouchIdEnroll': True, + 'wdaLaunchTimeout': 240000, + 'wdaLocalPort': 8100, + 'eventTimings': True, + 'app': os.getenv('FLUTTER_IOS_APP') + } + + return desired_caps diff --git a/test/functional/flutter/helper/test_helper.py b/test/functional/flutter/helper/test_helper.py new file mode 100644 index 00000000..4ac9f85d --- /dev/null +++ b/test/functional/flutter/helper/test_helper.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import os + +from appium import webdriver +from appium.options.flutter.base import FlutterOptions +from appium.webdriver.extensions.flutter.flutter_commands import FlutterCommand +from test.functional.test_helper import is_ci +from test.helpers.constants import SERVER_URL_BASE + +from . import desired_capabilities + + +class BaseTestCase(object): + + def setup_method(self) -> None: + platform_name = os.getenv("PLATFORM", "android").lower() + + # set flutter options + flutterOptions = FlutterOptions() + flutterOptions.flutter_system_port = 9999 + flutterOptions.flutter_enable_mock_camera = True + flutterOptions.flutter_element_wait_timeout = 10000 + flutterOptions.flutter_server_launch_timeout = 120000 + + desired_caps = desired_capabilities.get_desired_capabilities(platform_name) + self.driver = webdriver.Remote(SERVER_URL_BASE, options=flutterOptions.load_capabilities(desired_caps)) + self.flutter_command = FlutterCommand(self.driver) + if is_ci(): + self.driver.start_recording_screen() + + def teardown_method(self, method) -> None: # type: ignore + if not hasattr(self, 'driver'): + return + + if is_ci(): + payload = self.driver.stop_recording_screen() + video_path = os.path.join(os.getcwd(), method.__name__ + '.mp4') + with open(video_path, 'wb') as fd: + fd.write(base64.b64decode(payload)) + self.driver.quit() + From 596538c713dd96fd0b14f06deaa93a26a1b34af7 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Thu, 19 Sep 2024 20:51:14 +0200 Subject: [PATCH 02/21] feat: remove skip tags --- test/functional/flutter/commands_test.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/functional/flutter/commands_test.py b/test/functional/flutter/commands_test.py index 6a79f4c4..e78745a4 100644 --- a/test/functional/flutter/commands_test.py +++ b/test/functional/flutter/commands_test.py @@ -14,8 +14,6 @@ import os -import pytest - from appium.webdriver.common.flutterby import FlutterBy from appium.webdriver.flutter_finder import FlutterFinder from test.functional.flutter.helper.test_helper import BaseTestCase @@ -41,7 +39,6 @@ def test_wait_command(self): self.flutter_command.wait_for_visible(message_field) assert len(self.driver.find_elements(*message_field_finder.as_strings())) == 1 - @pytest.mark.skip def test_scroll_till_visible_command(self): self.__open_screen('Vertical Swiping') @@ -59,7 +56,6 @@ def test_scroll_till_visible_command(self): assert second_element.get_attribute('displayed') == 'false' assert first_element.get_attribute('displayed') == 'true' - @pytest.mark.skip def test_scroll_till_visible_with_scroll_params_command(self): self.__open_screen('Vertical Swiping') @@ -72,7 +68,6 @@ def test_scroll_till_visible_with_scroll_params_command(self): first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text("Playwright"), **scroll_params) assert first_element.get_attribute('displayed') == 'true' - @pytest.mark.skip def test_double_click_command(self): self.__open_screen('Double Tap') @@ -88,7 +83,6 @@ def test_double_click_command(self): self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'Ok').click() - @pytest.mark.skip def test_long_press_command(self): self.__open_screen('Long Press') @@ -99,7 +93,6 @@ def test_long_press_command(self): assert success_pop_up.text == 'It was a long press' assert success_pop_up.is_displayed() == True - @pytest.mark.skip def test_drag_and_drop_command(self): self.__open_screen('Drag & Drop') @@ -108,7 +101,6 @@ def test_drag_and_drop_command(self): self.flutter_command.perform_drag_and_drop(drag_element, drop_element) assert self.driver.find_element(FlutterBy.FLUTTER_TEXT,'The box is dropped').is_displayed() == True - @pytest.mark.skip def test_camera_mocking(self): self.__open_screen('Image Picker') From f9e163015227604d85291c5025dad1a8c0cfc785 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Fri, 20 Sep 2024 16:44:10 +0200 Subject: [PATCH 03/21] feat: Fix review comments --- .../__init__.py | 0 .../{flutter => flutter_integration}/base.py | 8 +-- .../flutter_element_wait_timeout_option.py | 26 ++++++-- .../flutter_enable_mock_camera_option.py | 15 ++++- .../flutter_server_launch_timeout_option.py | 27 ++++++-- .../flutter_system_port_option.py | 13 ++++ appium/webdriver/common/appiumby.py | 5 ++ appium/webdriver/common/flutterby.py | 25 ------- .../flutter_commands.py | 61 +++++++++++------- .../flutter_integration/scroll_directions.py | 8 +++ appium/webdriver/flutter_finder.py | 20 +++--- .../__init__.py | 0 .../commands_test.py | 57 ++++++++-------- .../file/second_qr.png | Bin .../file/success_qr.png | Bin .../finder_test.py | 24 +++---- .../helper/__init__.py | 0 .../helper/desired_capabilities.py | 0 .../helper/test_helper.py | 16 ++--- 19 files changed, 181 insertions(+), 124 deletions(-) rename appium/options/{flutter => flutter_integration}/__init__.py (100%) rename appium/options/{flutter => flutter_integration}/base.py (74%) rename appium/options/{flutter => flutter_integration}/flutter_element_wait_timeout_option.py (52%) rename appium/options/{flutter => flutter_integration}/flutter_enable_mock_camera_option.py (69%) rename appium/options/{flutter => flutter_integration}/flutter_server_launch_timeout_option.py (52%) rename appium/options/{flutter => flutter_integration}/flutter_system_port_option.py (75%) delete mode 100644 appium/webdriver/common/flutterby.py rename appium/webdriver/extensions/{flutter => flutter_integration}/flutter_commands.py (64%) create mode 100644 appium/webdriver/extensions/flutter_integration/scroll_directions.py rename test/functional/{flutter => flutter_integration}/__init__.py (100%) rename test/functional/{flutter => flutter_integration}/commands_test.py (64%) rename test/functional/{flutter => flutter_integration}/file/second_qr.png (100%) rename test/functional/{flutter => flutter_integration}/file/success_qr.png (100%) rename test/functional/{flutter => flutter_integration}/finder_test.py (71%) rename test/functional/{flutter => flutter_integration}/helper/__init__.py (100%) rename test/functional/{flutter => flutter_integration}/helper/desired_capabilities.py (100%) rename test/functional/{flutter => flutter_integration}/helper/test_helper.py (71%) diff --git a/appium/options/flutter/__init__.py b/appium/options/flutter_integration/__init__.py similarity index 100% rename from appium/options/flutter/__init__.py rename to appium/options/flutter_integration/__init__.py diff --git a/appium/options/flutter/base.py b/appium/options/flutter_integration/base.py similarity index 74% rename from appium/options/flutter/base.py rename to appium/options/flutter_integration/base.py index 98500b64..7cd36a12 100644 --- a/appium/options/flutter/base.py +++ b/appium/options/flutter_integration/base.py @@ -18,10 +18,10 @@ from typing import Dict from appium.options.common.automation_name_option import AUTOMATION_NAME from appium.options.common.base import AppiumOptions -from appium.options.flutter.flutter_element_wait_timeout_option import FlutterElementWaitTimeOutOption -from appium.options.flutter.flutter_enable_mock_camera_option import FlutterEnableMockCameraOption -from appium.options.flutter.flutter_server_launch_timeout_option import FlutterServerLaunchTimeOutOption -from appium.options.flutter.flutter_system_port_option import FlutterSystemPortOption +from appium.options.flutter_integration.flutter_element_wait_timeout_option import FlutterElementWaitTimeOutOption +from appium.options.flutter_integration.flutter_enable_mock_camera_option import FlutterEnableMockCameraOption +from appium.options.flutter_integration.flutter_server_launch_timeout_option import FlutterServerLaunchTimeOutOption +from appium.options.flutter_integration.flutter_system_port_option import FlutterSystemPortOption class FlutterOptions( diff --git a/appium/options/flutter/flutter_element_wait_timeout_option.py b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py similarity index 52% rename from appium/options/flutter/flutter_element_wait_timeout_option.py rename to appium/options/flutter_integration/flutter_element_wait_timeout_option.py index 5cfe2a92..c8c38a21 100644 --- a/appium/options/flutter/flutter_element_wait_timeout_option.py +++ b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py @@ -15,7 +15,8 @@ # specific language governing permissions and limitations # under the License. -from typing import Optional +from datetime import timedelta +from typing import Optional, Union from appium.options.common.supports_capabilities import SupportsCapabilities @@ -25,9 +26,26 @@ class FlutterElementWaitTimeOutOption(SupportsCapabilities): @property - def flutter_element_wait_timeout(self) -> Optional[int]: + def flutter_element_wait_timeout(self) -> Optional[timedelta]: + """ + Maximum timeout to wait for element for Flutter integration test + + Returns: + Optional[timedelta]: The timeout value as a `timedelta` object if set, or `None` if the timeout is not defined. + """ return self.get_capability(FLUTTER_ELEMENT_WAIT_TIMEOUT) @flutter_element_wait_timeout.setter - def flutter_element_wait_timeout(self, time_in_millis: int) -> None: - self.set_capability(FLUTTER_ELEMENT_WAIT_TIMEOUT, time_in_millis) \ No newline at end of file + def flutter_element_wait_timeout(self, value: Union[timedelta, int]) -> None: + """ + Sets the maximum timeout to wait for a Flutter element in an integration test. + Default timeout is 5000ms + + Args: + value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. + If provided as a `timedelta`, it will be converted to milliseconds. + """ + self.set_capability( + FLUTTER_ELEMENT_WAIT_TIMEOUT, + int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value + ) \ No newline at end of file diff --git a/appium/options/flutter/flutter_enable_mock_camera_option.py b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py similarity index 69% rename from appium/options/flutter/flutter_enable_mock_camera_option.py rename to appium/options/flutter_integration/flutter_enable_mock_camera_option.py index 9ce092d4..cc6abaa9 100644 --- a/appium/options/flutter/flutter_enable_mock_camera_option.py +++ b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py @@ -25,9 +25,22 @@ class FlutterEnableMockCameraOption(SupportsCapabilities): @property - def flutter_enable_mock_camera(self) -> Optional[int]: + def flutter_enable_mock_camera(self) -> bool: + """ + Get state of the mock camera for Flutter integration test + + Returns: + bool: A boolean indicating whether the mock camera is enabled (True) or disabled (False). + """ return self.get_capability(FLUTTER_ENABLE_MOCK_CAMERA) @flutter_enable_mock_camera.setter def flutter_enable_mock_camera(self, value: bool) -> None: + """ + Setter method enable or disable the mock camera for Flutter integration test + Default state is `False` + + Args: + value (bool): A boolean value indicating whether to enable (True) or disable (False) the mock camera. + """ self.set_capability(FLUTTER_ENABLE_MOCK_CAMERA, value) \ No newline at end of file diff --git a/appium/options/flutter/flutter_server_launch_timeout_option.py b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py similarity index 52% rename from appium/options/flutter/flutter_server_launch_timeout_option.py rename to appium/options/flutter_integration/flutter_server_launch_timeout_option.py index fcde2ea1..9e1476ec 100644 --- a/appium/options/flutter/flutter_server_launch_timeout_option.py +++ b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py @@ -15,7 +15,8 @@ # specific language governing permissions and limitations # under the License. -from typing import Optional +from datetime import timedelta +from typing import Optional, Union from appium.options.common.supports_capabilities import SupportsCapabilities @@ -25,9 +26,27 @@ class FlutterServerLaunchTimeOutOption(SupportsCapabilities): @property - def flutter_server_launch_timeout(self) -> Optional[int]: + def flutter_server_launch_timeout(self) -> Optional[timedelta]: + """ + Gets the current timeout for launching the Flutter server in a Flutter application. + + Returns: + Optional[timedelta]: The timeout value as a `timedelta` object if set, or `None` if the timeout is not defined. + + """ return self.get_capability(FLUTTER_SERVER_LAUNCH_TIMEOUT) @flutter_server_launch_timeout.setter - def flutter_server_launch_timeout(self, time_in_millis: int) -> None: - self.set_capability(FLUTTER_SERVER_LAUNCH_TIMEOUT, time_in_millis) \ No newline at end of file + def flutter_server_launch_timeout(self, value: Union[timedelta, int]) -> None: + """ + Sets the timeout for launching the Flutter server in Flutter application. + Default timeout is 5000ms + + Args: + value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. + If provided as a `timedelta`, it will be converted to milliseconds. + """ + self.set_capability( + FLUTTER_SERVER_LAUNCH_TIMEOUT, + int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value + ) \ No newline at end of file diff --git a/appium/options/flutter/flutter_system_port_option.py b/appium/options/flutter_integration/flutter_system_port_option.py similarity index 75% rename from appium/options/flutter/flutter_system_port_option.py rename to appium/options/flutter_integration/flutter_system_port_option.py index 6b0c9849..13574ea4 100644 --- a/appium/options/flutter/flutter_system_port_option.py +++ b/appium/options/flutter_integration/flutter_system_port_option.py @@ -26,8 +26,21 @@ class FlutterSystemPortOption(SupportsCapabilities): @property def flutter_system_port(self) -> Optional[int]: + """ + Get flutter system port for Flutter integration tests. + + Returns: + int: returns the port number + """ return self.get_capability(FLUTTER_SYSTEM_PORT) @flutter_system_port.setter def flutter_system_port(self, value: int) -> None: + """ + Sets the system port for Flutter integration tests. + By default the first free port from 10000..11000 range is selected + + Args: + value (int): The port number to be used for the Flutter server. + """ self.set_capability(FLUTTER_SYSTEM_PORT, value) \ No newline at end of file diff --git a/appium/webdriver/common/appiumby.py b/appium/webdriver/common/appiumby.py index 7632ce35..d5118c8c 100644 --- a/appium/webdriver/common/appiumby.py +++ b/appium/webdriver/common/appiumby.py @@ -25,3 +25,8 @@ class AppiumBy(By): ACCESSIBILITY_ID = 'accessibility id' IMAGE = '-image' CUSTOM = '-custom' + FLUTTER_INTEGRATION_SEMANTICS_LABEL = '-flutter semantics label' + FLUTTER_INTEGRATION_TYPE = '-flutter type' + FLUTTER_INTEGRATION_KEY = '-flutter key' + FLUTTER_INTEGRATION_TEXT = '-flutter text' + FLUTTER_INTEGRATION_TEXT_CONTAINING = '-flutter text containing' \ No newline at end of file diff --git a/appium/webdriver/common/flutterby.py b/appium/webdriver/common/flutterby.py deleted file mode 100644 index 51c98c34..00000000 --- a/appium/webdriver/common/flutterby.py +++ /dev/null @@ -1,25 +0,0 @@ -# Licensed to the Software Freedom Conservancy (SFC) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The SFC licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -from selenium.webdriver.common.by import By - -class FlutterBy(By): - FLUTTER_SEMANTICS_LABEL = '-flutter semantics label' - FLUTTER_TYPE = '-flutter type' - FLUTTER_KEY = '-flutter key' - FLUTTER_TEXT = '-flutter text' - FLUTTER_TEXT_CONTAINING = '-flutter text containing' diff --git a/appium/webdriver/extensions/flutter/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py similarity index 64% rename from appium/webdriver/extensions/flutter/flutter_commands.py rename to appium/webdriver/extensions/flutter_integration/flutter_commands.py index 62d555c3..28e691b8 100644 --- a/appium/webdriver/extensions/flutter/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -13,26 +13,28 @@ # limitations under the License. import base64 +import os from typing import Any, Optional, Tuple, Union +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.flutter_finder import FlutterFinder from appium.webdriver.webdriver import WebDriver from appium.webdriver.webelement import WebElement -class FlutterCommand(): +class FlutterCommand: def __init__(self, driver: WebDriver) -> None: self.driver = driver # wait commands - def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[int] = None) -> None: + def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: """ Waits for a element to become visible. Args: - locator: The element to wait for; can be a WebElement or a FlutterFinder. - time_out: Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. + time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. Returns: None: @@ -45,13 +47,13 @@ def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: self.execute_flutter_command('waitForVisible', opts) - def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[int] = None) -> None: + def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: """ Waits for a element to become invisible. Args: - locator: The element to wait for; can be a WebElement or a FlutterFinder. - time_out: Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. + time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. Returns: None: @@ -70,14 +72,14 @@ def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, Performs a double-click on the given element, with an optional offset. Args: - element: The element to double-click on. This parameter is required. - offset: The x and y offsets from the element to click at. If not specified, the click is performed at the element's center. + element (WebElement): The element to double-click on. This parameter is required. + offset (Optional[Tuple[int, int]]): The x and y offsets from the element to click at. If not specified, the click is performed at the element's center. Returns: None: """ opts = {'origin': element} - if offset: + if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('doubleClick', opts) @@ -86,14 +88,14 @@ def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, in Performs a long press on the given element, with an optional offset. Args: - element: The element to perform the long press on. This parameter is required. - offset: The x and y offsets from the element to perform the long press at. If not specified, the long press is performed at the element's center. + element (WebElement): The element to perform the long press on. This parameter is required. + offset (Optional[Tuple[int, int]]): The x and y offsets from the element to perform the long press at. If not specified, the long press is performed at the element's center. Returns: None: """ opts = {'origin': element} - if offset: + if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('longPress', opts) @@ -102,21 +104,21 @@ def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: Performs a drag-and-drop operation from a source element to a target element. Args: - source: The element to drag from. - target: The element to drop onto. + source (WebElement): The element to drag from. + target (WebElement): The element to drop onto. Returns: None: """ self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) - def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: Optional[str] = 'down', **opts: Any) -> WebElement: + def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: Optional[ScrollDirection] = ScrollDirection.DOWN, **opts: Any) -> WebElement: """ Scrolls until the specified element becomes visible. Args: - scroll_to: The Flutter element to scroll to. - scroll_direction: The direction to scroll up/down. Defaults to 'down'. + scroll_to (FlutterFinder): The Flutter element to scroll to. + scroll_direction (Optional[ScrollDirection]): The direction to scroll up or down. Defaults to `ScrollDirection.DOWN`. KeywordArgs: scrollView (str): The view of the scroll. @@ -129,7 +131,7 @@ def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: Option Webelement: scrolled element """ opts['finder'] = scroll_to.to_dict() - opts['scrollDirection'] = scroll_direction + opts['scrollDirection'] = scroll_direction.as_string() return self.execute_flutter_command('scrollTillVisible', opts) def inject_mock_image(self, value: str) -> str: @@ -137,14 +139,13 @@ def inject_mock_image(self, value: str) -> str: Injects a mock image to the device. The input can be a file path or a base64-encoded string. Args: - value: The file path of the image or a base64-encoded string. + value (str): The file path of the image or a base64-encoded string. Returns: str: Image ID of the injected image. """ - import os if os.path.isfile(value): - with open(value, "rb") as image_file: + with open(value, 'rb') as image_file: base64_encoded_image = base64.b64encode(image_file.read()).decode('utf-8') else: base64_encoded_image = value @@ -155,12 +156,24 @@ def activate_injected_image(self, image_id: str) -> None: Activates an injected image with image ID. Args: - image_id: The ID of the injected image to activate. + image_id (str): The ID of the injected image to activate. Returns: None: """ - self.execute_flutter_command("activateInjectedImage", {'imageId': image_id}) + self.execute_flutter_command('activateInjectedImage', {'imageId': image_id}) def execute_flutter_command(self, scriptName: str, params: dict) -> Any: + """ + Executes a Flutter command by sending a script and parameters to the flutter integration driver. + + Args: + scriptName (str): The name of the Flutter command to execute. + This will be prefixed with 'flutter:' when passed to the driver. + params (dict): A dictionary of parameters to be passed along with the Flutter command. + + Returns: + Any: The result of the command execution. The return value depends on the + specific Flutter command being executed. + """ return self.driver.execute_script(f'flutter: {scriptName}', params) \ No newline at end of file diff --git a/appium/webdriver/extensions/flutter_integration/scroll_directions.py b/appium/webdriver/extensions/flutter_integration/scroll_directions.py new file mode 100644 index 00000000..8650c202 --- /dev/null +++ b/appium/webdriver/extensions/flutter_integration/scroll_directions.py @@ -0,0 +1,8 @@ +from enum import Enum + +class ScrollDirection(Enum): + UP = 'up' + DOWN = 'down' + + def as_string(self): + return str(self.value) \ No newline at end of file diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/flutter_finder.py index b7a77759..3ed3fbb6 100644 --- a/appium/webdriver/flutter_finder.py +++ b/appium/webdriver/flutter_finder.py @@ -15,9 +15,9 @@ # specific language governing permissions and limitations # under the License. -from appium.webdriver.common.flutterby import FlutterBy +from appium.webdriver.common.appiumby import AppiumBy -class FlutterFinder(): +class FlutterFinder: def __init__(self, using: str, value: str) -> None: self.using = using @@ -25,27 +25,27 @@ def __init__(self, using: str, value: str) -> None: @staticmethod def by_flutter_key(value: str) -> 'FlutterFinder': - return FlutterFinder(FlutterBy.FLUTTER_KEY, value) + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_KEY, value) @staticmethod def by_flutter_text(value: str) -> 'FlutterFinder': - return FlutterFinder(FlutterBy.FLUTTER_TEXT, value) + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT, value) @staticmethod def by_flutter_semantics_label(value: str) -> 'FlutterFinder': - return FlutterFinder(FlutterBy.FLUTTER_SEMANTICS_LABEL, value) + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, value) @staticmethod def by_flutter_type(value: str) -> 'FlutterFinder': - return FlutterFinder(FlutterBy.FLUTTER_TYPE, value) + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TYPE, value) @staticmethod def by_flutter_text_containing(value: str) -> 'FlutterFinder': - return FlutterFinder(FlutterBy.FLUTTER_TEXT_CONTAINING, value) + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, value) - def to_dict(self): - return { "using": self.using, "value": self.value} + def to_dict(self) -> dict: + return { 'using': self.using, 'value': self.value} - def as_strings(self) -> str: + def as_args(self) -> str: return self.using, self.value diff --git a/test/functional/flutter/__init__.py b/test/functional/flutter_integration/__init__.py similarity index 100% rename from test/functional/flutter/__init__.py rename to test/functional/flutter_integration/__init__.py diff --git a/test/functional/flutter/commands_test.py b/test/functional/flutter_integration/commands_test.py similarity index 64% rename from test/functional/flutter/commands_test.py rename to test/functional/flutter_integration/commands_test.py index e78745a4..0ea4fb30 100644 --- a/test/functional/flutter/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -14,9 +14,10 @@ import os -from appium.webdriver.common.flutterby import FlutterBy +from appium.webdriver.common.appiumby import AppiumBy +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.flutter_finder import FlutterFinder -from test.functional.flutter.helper.test_helper import BaseTestCase +from test.functional.flutter_integration.helper.test_helper import BaseTestCase class TestFlutterCommands(BaseTestCase): @@ -26,24 +27,24 @@ def test_wait_command(self): message_field_finder = FlutterFinder.by_flutter_key('message_field') toggle_button_finder = FlutterFinder.by_flutter_key('toggle_button') - message_field = self.driver.find_element(*message_field_finder.as_strings()) - toggle_button = self.driver.find_element(*toggle_button_finder.as_strings()) + message_field = self.driver.find_element(*message_field_finder.as_args()) + toggle_button = self.driver.find_element(*toggle_button_finder.as_args()) assert message_field.is_displayed() == True assert message_field.text == 'Hello world' toggle_button.click() self.flutter_command.wait_for_invisible(message_field_finder) - assert len(self.driver.find_elements(*message_field_finder.as_strings())) == 0 + assert len(self.driver.find_elements(*message_field_finder.as_args())) == 0 toggle_button.click() self.flutter_command.wait_for_visible(message_field) - assert len(self.driver.find_elements(*message_field_finder.as_strings())) == 1 + assert len(self.driver.find_elements(*message_field_finder.as_args())) == 1 def test_scroll_till_visible_command(self): self.__open_screen('Vertical Swiping') - java_text_finder = FlutterFinder.by_flutter_text("Java") - protractor_text_finder = FlutterFinder.by_flutter_text("Protractor") + java_text_finder = FlutterFinder.by_flutter_text('Java') + protractor_text_finder = FlutterFinder.by_flutter_text('Protractor') first_element = self.flutter_command.scroll_till_visible(java_text_finder) assert first_element.get_attribute('displayed') == 'true' @@ -52,54 +53,54 @@ def test_scroll_till_visible_command(self): assert second_element.get_attribute('displayed') == 'true' assert first_element.get_attribute('displayed') == 'false' - first_element = self.flutter_command.scroll_till_visible(java_text_finder, scroll_direction='up') + first_element = self.flutter_command.scroll_till_visible(java_text_finder, ScrollDirection.UP) assert second_element.get_attribute('displayed') == 'false' assert first_element.get_attribute('displayed') == 'true' def test_scroll_till_visible_with_scroll_params_command(self): self.__open_screen('Vertical Swiping') - scroll_params = {'scrollView': FlutterFinder.by_flutter_type("Scrollable").to_dict(), + scroll_params = {'scrollView': FlutterFinder.by_flutter_type('Scrollable').to_dict(), 'delta': 30, 'maxScrolls': 30, 'settleBetweenScrollsTimeout': 5000, 'dragDuration': 35 } - first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text("Playwright"), **scroll_params) + first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Playwright'), **scroll_params) assert first_element.get_attribute('displayed') == 'true' def test_double_click_command(self): self.__open_screen('Double Tap') - double_tap_button = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'double_tap_button').find_element(FlutterBy.FLUTTER_TEXT, 'Double Tap') + double_tap_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'double_tap_button').find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Double Tap') assert double_tap_button.text == 'Double Tap' self.flutter_command.perform_double_click(double_tap_button) - assert self.driver.find_element(FlutterBy.FLUTTER_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' - self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'Ok').click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() self.flutter_command.perform_double_click(double_tap_button, (10,2)) - assert self.driver.find_element(FlutterBy.FLUTTER_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' - self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'Ok').click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() def test_long_press_command(self): self.__open_screen('Long Press') - long_press_button = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'long_press_button') + long_press_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'long_press_button') self.flutter_command.perform_long_press(long_press_button) - success_pop_up = self.driver.find_element(FlutterBy.FLUTTER_TEXT,'It was a long press') + success_pop_up = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'It was a long press') assert success_pop_up.text == 'It was a long press' assert success_pop_up.is_displayed() == True def test_drag_and_drop_command(self): self.__open_screen('Drag & Drop') - drag_element = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'drag_me') - drop_element = self.driver.find_element(FlutterBy.FLUTTER_KEY, 'drop_zone') + drag_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drag_me') + drop_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drop_zone') self.flutter_command.perform_drag_and_drop(drag_element, drop_element) - assert self.driver.find_element(FlutterBy.FLUTTER_TEXT,'The box is dropped').is_displayed() == True + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'The box is dropped').is_displayed() == True def test_camera_mocking(self): self.__open_screen('Image Picker') @@ -109,17 +110,17 @@ def test_camera_mocking(self): image_id = self.flutter_command.inject_mock_image(success_qr_file_path) self.flutter_command.inject_mock_image(second_qr_file_path) - self.driver.find_element(FlutterBy.FLUTTER_KEY, 'capture_image').click() - self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'PICK').click() - assert self.driver.find_element(FlutterBy.FLUTTER_TEXT,'SecondInjectedImage').is_displayed() == True + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'SecondInjectedImage').is_displayed() == True self.flutter_command.activate_injected_image(image_id) - self.driver.find_element(FlutterBy.FLUTTER_KEY, 'capture_image').click() - self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'PICK').click() - assert self.driver.find_element(FlutterBy.FLUTTER_TEXT,'Success!').is_displayed() == True + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'Success!').is_displayed() == True def __open_screen(self, screen_name: str) -> None: - self.driver.find_element(FlutterBy.FLUTTER_TEXT, 'Login').click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Login').click() element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text(screen_name)) element.click() diff --git a/test/functional/flutter/file/second_qr.png b/test/functional/flutter_integration/file/second_qr.png similarity index 100% rename from test/functional/flutter/file/second_qr.png rename to test/functional/flutter_integration/file/second_qr.png diff --git a/test/functional/flutter/file/success_qr.png b/test/functional/flutter_integration/file/success_qr.png similarity index 100% rename from test/functional/flutter/file/success_qr.png rename to test/functional/flutter_integration/file/success_qr.png diff --git a/test/functional/flutter/finder_test.py b/test/functional/flutter_integration/finder_test.py similarity index 71% rename from test/functional/flutter/finder_test.py rename to test/functional/flutter_integration/finder_test.py index a1e60a87..dee2c610 100644 --- a/test/functional/flutter/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from appium.webdriver.common.flutterby import FlutterBy +from appium.webdriver.common.appiumby import AppiumBy from appium.webdriver.flutter_finder import FlutterFinder -from test.functional.flutter.helper.test_helper import BaseTestCase +from test.functional.flutter_integration.helper.test_helper import BaseTestCase LOGIN_BUTTON_FINDER = FlutterFinder.by_flutter_text('Login') @@ -22,35 +22,35 @@ class TestFlutterFinders(BaseTestCase): def test_by_flutter_key(self): user_name_field_finder = FlutterFinder.by_flutter_key('username_text_field') - user_name_field = self.driver.find_element(*user_name_field_finder.as_strings()) + user_name_field = self.driver.find_element(*user_name_field_finder.as_args()) assert user_name_field.text == 'admin' user_name_field.clear() - user_name_field = self.driver.find_element(*user_name_field_finder.as_strings()).send_keys('admin123') + user_name_field = self.driver.find_element(*user_name_field_finder.as_args()).send_keys('admin123') assert user_name_field.text == 'admin123' def test_by_flutter_type(self): - login_button = self.driver.find_element(FlutterBy.FLUTTER_TYPE,'ElevatedButton') - assert login_button.find_element(FlutterBy.FLUTTER_TYPE, 'Text').text == 'Login' + login_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE,'ElevatedButton') + assert login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text == 'Login' def test_by_flutter_text(self): - login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_strings()) + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) assert login_button.text == 'Login' login_button.click() - slider = self.driver.find_elements(FlutterBy.FLUTTER_TEXT, 'Slider') + slider = self.driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Slider') assert len(slider) == 1 def test_by_flutter_text_containing(self): - login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_strings()) + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() - vertical_swipe_label = self.driver.find_element(FlutterBy.FLUTTER_TEXT_CONTAINING, 'Vertical') + vertical_swipe_label = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical') assert vertical_swipe_label.text == 'Vertical Swiping' def test_by_flutter_semantics_label(self): - login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_strings()) + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Lazy Loading')) element.click() - message_field = self.driver.find_element(FlutterBy.FLUTTER_SEMANTICS_LABEL, 'message_field') + message_field = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field') assert message_field.text == 'Hello world' \ No newline at end of file diff --git a/test/functional/flutter/helper/__init__.py b/test/functional/flutter_integration/helper/__init__.py similarity index 100% rename from test/functional/flutter/helper/__init__.py rename to test/functional/flutter_integration/helper/__init__.py diff --git a/test/functional/flutter/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py similarity index 100% rename from test/functional/flutter/helper/desired_capabilities.py rename to test/functional/flutter_integration/helper/desired_capabilities.py diff --git a/test/functional/flutter/helper/test_helper.py b/test/functional/flutter_integration/helper/test_helper.py similarity index 71% rename from test/functional/flutter/helper/test_helper.py rename to test/functional/flutter_integration/helper/test_helper.py index 4ac9f85d..62761f09 100644 --- a/test/functional/flutter/helper/test_helper.py +++ b/test/functional/flutter_integration/helper/test_helper.py @@ -16,8 +16,8 @@ import os from appium import webdriver -from appium.options.flutter.base import FlutterOptions -from appium.webdriver.extensions.flutter.flutter_commands import FlutterCommand +from appium.options.flutter_integration.base import FlutterOptions +from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand from test.functional.test_helper import is_ci from test.helpers.constants import SERVER_URL_BASE @@ -27,7 +27,7 @@ class BaseTestCase(object): def setup_method(self) -> None: - platform_name = os.getenv("PLATFORM", "android").lower() + platform_name = os.getenv('PLATFORM', 'android').lower() # set flutter options flutterOptions = FlutterOptions() @@ -39,17 +39,9 @@ def setup_method(self) -> None: desired_caps = desired_capabilities.get_desired_capabilities(platform_name) self.driver = webdriver.Remote(SERVER_URL_BASE, options=flutterOptions.load_capabilities(desired_caps)) self.flutter_command = FlutterCommand(self.driver) - if is_ci(): - self.driver.start_recording_screen() - def teardown_method(self, method) -> None: # type: ignore + def teardown_method(self) -> None: # type: ignore if not hasattr(self, 'driver'): return - - if is_ci(): - payload = self.driver.stop_recording_screen() - video_path = os.path.join(os.getcwd(), method.__name__ + '.mp4') - with open(video_path, 'wb') as fd: - fd.write(base64.b64decode(payload)) self.driver.quit() From 7694af19320b872de0b9f4563d73b648e8bea46a Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Fri, 20 Sep 2024 22:41:21 +0200 Subject: [PATCH 04/21] feat: fix code style --- .../flutter_integration/flutter_commands.py | 24 ++++++++++++------- .../flutter_integration/scroll_directions.py | 2 +- appium/webdriver/flutter_finder.py | 2 +- .../flutter_integration/commands_test.py | 16 ++++++------- .../flutter_integration/finder_test.py | 10 ++++---- .../helper/desired_capabilities.py | 9 +++---- 6 files changed, 35 insertions(+), 28 deletions(-) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 28e691b8..7181e5f8 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -14,7 +14,7 @@ import base64 import os -from typing import Any, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.flutter_finder import FlutterFinder from appium.webdriver.webdriver import WebDriver @@ -39,10 +39,13 @@ def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Returns: None: """ + opts: Dict[str, Union[WebElement, Dict[str, str], float]] = {} if isinstance(locator, WebElement): - opts = {'element': locator, 'timeout': time_out} + opts['element'] = locator else: - opts = {'locator': locator.to_dict(), 'timeout': time_out} + opts['locator'] = locator.to_dict() + if time_out is not None: + opts['timeout'] = time_out self.execute_flutter_command('waitForVisible', opts) @@ -58,10 +61,13 @@ def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out Returns: None: """ + opts: Dict[str, Union[WebElement, Dict[str, str], float]] = {} if isinstance(locator, WebElement): - opts = {'element': locator, 'timeout': time_out} + opts['element'] = locator else: - opts = {'locator': locator.to_dict(), 'timeout': time_out} + opts['locator'] = locator.to_dict() + if time_out is not None: + opts['timeout'] = time_out self.execute_flutter_command('waitForAbsent', opts) @@ -78,7 +84,7 @@ def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, Returns: None: """ - opts = {'origin': element} + opts: Dict[str, Union[WebElement, Dict[str, int]]] = {'origin': element} if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('doubleClick', opts) @@ -94,7 +100,7 @@ def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, in Returns: None: """ - opts = {'origin': element} + opts: Dict[str, Union[WebElement, Dict[str, int]]]= {'origin': element} if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('longPress', opts) @@ -112,13 +118,13 @@ def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: """ self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) - def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: Optional[ScrollDirection] = ScrollDirection.DOWN, **opts: Any) -> WebElement: + def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: ScrollDirection = ScrollDirection.DOWN, **opts: Any) -> WebElement: """ Scrolls until the specified element becomes visible. Args: scroll_to (FlutterFinder): The Flutter element to scroll to. - scroll_direction (Optional[ScrollDirection]): The direction to scroll up or down. Defaults to `ScrollDirection.DOWN`. + scroll_direction (ScrollDirection): The direction to scroll up or down. Defaults to `ScrollDirection.DOWN`. KeywordArgs: scrollView (str): The view of the scroll. diff --git a/appium/webdriver/extensions/flutter_integration/scroll_directions.py b/appium/webdriver/extensions/flutter_integration/scroll_directions.py index 8650c202..2249a370 100644 --- a/appium/webdriver/extensions/flutter_integration/scroll_directions.py +++ b/appium/webdriver/extensions/flutter_integration/scroll_directions.py @@ -4,5 +4,5 @@ class ScrollDirection(Enum): UP = 'up' DOWN = 'down' - def as_string(self): + def as_string(self) -> str: return str(self.value) \ No newline at end of file diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/flutter_finder.py index 3ed3fbb6..044331f8 100644 --- a/appium/webdriver/flutter_finder.py +++ b/appium/webdriver/flutter_finder.py @@ -46,6 +46,6 @@ def by_flutter_text_containing(value: str) -> 'FlutterFinder': def to_dict(self) -> dict: return { 'using': self.using, 'value': self.value} - def as_args(self) -> str: + def as_args(self) -> tuple[str, str]: return self.using, self.value diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py index 0ea4fb30..ebcce015 100644 --- a/test/functional/flutter_integration/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -21,7 +21,7 @@ class TestFlutterCommands(BaseTestCase): - def test_wait_command(self): + def test_wait_command(self) -> None: self.__open_screen('Lazy Loading') message_field_finder = FlutterFinder.by_flutter_key('message_field') @@ -40,7 +40,7 @@ def test_wait_command(self): self.flutter_command.wait_for_visible(message_field) assert len(self.driver.find_elements(*message_field_finder.as_args())) == 1 - def test_scroll_till_visible_command(self): + def test_scroll_till_visible_command(self) -> None: self.__open_screen('Vertical Swiping') java_text_finder = FlutterFinder.by_flutter_text('Java') @@ -57,7 +57,7 @@ def test_scroll_till_visible_command(self): assert second_element.get_attribute('displayed') == 'false' assert first_element.get_attribute('displayed') == 'true' - def test_scroll_till_visible_with_scroll_params_command(self): + def test_scroll_till_visible_with_scroll_params_command(self) -> None: self.__open_screen('Vertical Swiping') scroll_params = {'scrollView': FlutterFinder.by_flutter_type('Scrollable').to_dict(), @@ -66,10 +66,10 @@ def test_scroll_till_visible_with_scroll_params_command(self): 'settleBetweenScrollsTimeout': 5000, 'dragDuration': 35 } - first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Playwright'), **scroll_params) + first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params) assert first_element.get_attribute('displayed') == 'true' - def test_double_click_command(self): + def test_double_click_command(self) -> None: self.__open_screen('Double Tap') double_tap_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'double_tap_button').find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Double Tap') @@ -84,7 +84,7 @@ def test_double_click_command(self): self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() - def test_long_press_command(self): + def test_long_press_command(self) -> None: self.__open_screen('Long Press') long_press_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'long_press_button') @@ -94,7 +94,7 @@ def test_long_press_command(self): assert success_pop_up.text == 'It was a long press' assert success_pop_up.is_displayed() == True - def test_drag_and_drop_command(self): + def test_drag_and_drop_command(self) -> None: self.__open_screen('Drag & Drop') drag_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drag_me') @@ -102,7 +102,7 @@ def test_drag_and_drop_command(self): self.flutter_command.perform_drag_and_drop(drag_element, drop_element) assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'The box is dropped').is_displayed() == True - def test_camera_mocking(self): + def test_camera_mocking(self) -> None: self.__open_screen('Image Picker') success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py index dee2c610..ce688f61 100644 --- a/test/functional/flutter_integration/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -20,7 +20,7 @@ class TestFlutterFinders(BaseTestCase): - def test_by_flutter_key(self): + def test_by_flutter_key(self) -> None: user_name_field_finder = FlutterFinder.by_flutter_key('username_text_field') user_name_field = self.driver.find_element(*user_name_field_finder.as_args()) assert user_name_field.text == 'admin' @@ -29,11 +29,11 @@ def test_by_flutter_key(self): user_name_field = self.driver.find_element(*user_name_field_finder.as_args()).send_keys('admin123') assert user_name_field.text == 'admin123' - def test_by_flutter_type(self): + def test_by_flutter_type(self) -> None: login_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE,'ElevatedButton') assert login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text == 'Login' - def test_by_flutter_text(self): + def test_by_flutter_text(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) assert login_button.text == 'Login' @@ -41,13 +41,13 @@ def test_by_flutter_text(self): slider = self.driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Slider') assert len(slider) == 1 - def test_by_flutter_text_containing(self): + def test_by_flutter_text_containing(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() vertical_swipe_label = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical') assert vertical_swipe_label.text == 'Vertical Swiping' - def test_by_flutter_semantics_label(self): + def test_by_flutter_semantics_label(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Lazy Loading')) diff --git a/test/functional/flutter_integration/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py index bc621b64..ade0d71b 100644 --- a/test/functional/flutter_integration/helper/desired_capabilities.py +++ b/test/functional/flutter_integration/helper/desired_capabilities.py @@ -16,17 +16,18 @@ from typing import Any, Dict def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: + desired_caps: Dict[str, Any] = {} if platform_name == 'android': - desired_caps: Dict[str, Any] = { + desired_caps.update({ 'platformName': 'Android', 'deviceName': 'Android Emulator', 'newCommandTimeout': 240, 'uiautomator2ServerInstallTimeout': 120000, 'adbExecTimeout': 120000, 'app': os.getenv('FLUTTER_ANDROID_APP') - } + }) else: - desired_caps: Dict[str, Any] = { + desired_caps.update({ 'deviceName': os.getenv('IPHONE_MODEL'), 'platformName': 'iOS', 'platformVersion': os.getenv('IOS_VERSION'), @@ -35,6 +36,6 @@ def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: 'wdaLocalPort': 8100, 'eventTimings': True, 'app': os.getenv('FLUTTER_IOS_APP') - } + }) return desired_caps From bfbab1de6c35cfdf1bff9ba2fde1c002d3cfab45 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 00:07:02 +0200 Subject: [PATCH 05/21] feat: fix review comments and applied black and isort formating --- .../options/flutter_integration/__init__.py | 2 +- appium/options/flutter_integration/base.py | 6 +- .../flutter_element_wait_timeout_option.py | 12 ++-- .../flutter_enable_mock_camera_option.py | 10 +-- .../flutter_server_launch_timeout_option.py | 12 ++-- .../flutter_system_port_option.py | 8 +-- .../flutter_integration/flutter_commands.py | 72 +++++++++---------- .../flutter_integration/scroll_directions.py | 4 +- appium/webdriver/flutter_finder.py | 30 ++++---- .../flutter_integration/commands_test.py | 15 ++-- .../flutter_integration/finder_test.py | 7 +- .../helper/desired_capabilities.py | 1 + .../flutter_integration/helper/test_helper.py | 7 +- 13 files changed, 93 insertions(+), 93 deletions(-) diff --git a/appium/options/flutter_integration/__init__.py b/appium/options/flutter_integration/__init__.py index 3e9808cd..865d653e 100644 --- a/appium/options/flutter_integration/__init__.py +++ b/appium/options/flutter_integration/__init__.py @@ -1 +1 @@ -from .base import FlutterOptions \ No newline at end of file +from .base import FlutterOptions diff --git a/appium/options/flutter_integration/base.py b/appium/options/flutter_integration/base.py index 7cd36a12..65d1c19a 100644 --- a/appium/options/flutter_integration/base.py +++ b/appium/options/flutter_integration/base.py @@ -16,6 +16,7 @@ # under the License. from typing import Dict + from appium.options.common.automation_name_option import AUTOMATION_NAME from appium.options.common.base import AppiumOptions from appium.options.flutter_integration.flutter_element_wait_timeout_option import FlutterElementWaitTimeOutOption @@ -29,12 +30,11 @@ class FlutterOptions( FlutterElementWaitTimeOutOption, FlutterEnableMockCameraOption, FlutterServerLaunchTimeOutOption, - FlutterSystemPortOption + FlutterSystemPortOption, ): - + @property def default_capabilities(self) -> Dict: return { AUTOMATION_NAME: 'FlutterIntegration', } - \ No newline at end of file diff --git a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py index c8c38a21..ee2932c4 100644 --- a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py +++ b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py @@ -17,19 +17,19 @@ from datetime import timedelta from typing import Optional, Union -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities -FLUTTER_ELEMENT_WAIT_TIMEOUT= 'flutterElementWaitTimeout' +FLUTTER_ELEMENT_WAIT_TIMEOUT = 'flutterElementWaitTimeout' class FlutterElementWaitTimeOutOption(SupportsCapabilities): - + @property def flutter_element_wait_timeout(self) -> Optional[timedelta]: """ Maximum timeout to wait for element for Flutter integration test - + Returns: Optional[timedelta]: The timeout value as a `timedelta` object if set, or `None` if the timeout is not defined. """ @@ -46,6 +46,6 @@ def flutter_element_wait_timeout(self, value: Union[timedelta, int]) -> None: If provided as a `timedelta`, it will be converted to milliseconds. """ self.set_capability( - FLUTTER_ELEMENT_WAIT_TIMEOUT, + FLUTTER_ELEMENT_WAIT_TIMEOUT, int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value - ) \ No newline at end of file + ) diff --git a/appium/options/flutter_integration/flutter_enable_mock_camera_option.py b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py index cc6abaa9..7b335b25 100644 --- a/appium/options/flutter_integration/flutter_enable_mock_camera_option.py +++ b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py @@ -16,19 +16,19 @@ # under the License. from typing import Optional -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities FLUTTER_ENABLE_MOCK_CAMERA = 'flutterEnableMockCamera' class FlutterEnableMockCameraOption(SupportsCapabilities): - + @property def flutter_enable_mock_camera(self) -> bool: """ Get state of the mock camera for Flutter integration test - + Returns: bool: A boolean indicating whether the mock camera is enabled (True) or disabled (False). """ @@ -36,11 +36,11 @@ def flutter_enable_mock_camera(self) -> bool: @flutter_enable_mock_camera.setter def flutter_enable_mock_camera(self, value: bool) -> None: - """ + """ Setter method enable or disable the mock camera for Flutter integration test Default state is `False` Args: value (bool): A boolean value indicating whether to enable (True) or disable (False) the mock camera. """ - self.set_capability(FLUTTER_ENABLE_MOCK_CAMERA, value) \ No newline at end of file + self.set_capability(FLUTTER_ENABLE_MOCK_CAMERA, value) diff --git a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py index 9e1476ec..c09532e0 100644 --- a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py +++ b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py @@ -17,14 +17,14 @@ from datetime import timedelta from typing import Optional, Union -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities -FLUTTER_SERVER_LAUNCH_TIMEOUT= 'flutterServerLaunchTimeout' +FLUTTER_SERVER_LAUNCH_TIMEOUT = 'flutterServerLaunchTimeout' class FlutterServerLaunchTimeOutOption(SupportsCapabilities): - + @property def flutter_server_launch_timeout(self) -> Optional[timedelta]: """ @@ -43,10 +43,10 @@ def flutter_server_launch_timeout(self, value: Union[timedelta, int]) -> None: Default timeout is 5000ms Args: - value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. + value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. If provided as a `timedelta`, it will be converted to milliseconds. """ self.set_capability( - FLUTTER_SERVER_LAUNCH_TIMEOUT, + FLUTTER_SERVER_LAUNCH_TIMEOUT, int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value - ) \ No newline at end of file + ) diff --git a/appium/options/flutter_integration/flutter_system_port_option.py b/appium/options/flutter_integration/flutter_system_port_option.py index 13574ea4..2e049dd7 100644 --- a/appium/options/flutter_integration/flutter_system_port_option.py +++ b/appium/options/flutter_integration/flutter_system_port_option.py @@ -16,19 +16,19 @@ # under the License. from typing import Optional -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities FLUTTER_SYSTEM_PORT = 'flutterSystemPort' class FlutterSystemPortOption(SupportsCapabilities): - + @property def flutter_system_port(self) -> Optional[int]: """ Get flutter system port for Flutter integration tests. - + Returns: int: returns the port number """ @@ -43,4 +43,4 @@ def flutter_system_port(self, value: int) -> None: Args: value (int): The port number to be used for the Flutter server. """ - self.set_capability(FLUTTER_SYSTEM_PORT, value) \ No newline at end of file + self.set_capability(FLUTTER_SYSTEM_PORT, value) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 7181e5f8..4f7e1072 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -15,6 +15,7 @@ import base64 import os from typing import Any, Dict, Optional, Tuple, Union + from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.flutter_finder import FlutterFinder from appium.webdriver.webdriver import WebDriver @@ -22,12 +23,12 @@ class FlutterCommand: - + def __init__(self, driver: WebDriver) -> None: self.driver = driver - - # wait commands - + + # wait commands + def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: """ Waits for a element to become visible. @@ -35,9 +36,9 @@ def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Args: locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. - - Returns: - None: + + Returns: + None: """ opts: Dict[str, Union[WebElement, Dict[str, str], float]] = {} if isinstance(locator, WebElement): @@ -49,7 +50,6 @@ def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: self.execute_flutter_command('waitForVisible', opts) - def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: """ Waits for a element to become invisible. @@ -57,9 +57,9 @@ def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out Args: locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. - + Returns: - None: + None: """ opts: Dict[str, Union[WebElement, Dict[str, str], float]] = {} if isinstance(locator, WebElement): @@ -68,11 +68,11 @@ def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out opts['locator'] = locator.to_dict() if time_out is not None: opts['timeout'] = time_out - + self.execute_flutter_command('waitForAbsent', opts) # flutter action commands - + def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: """ Performs a double-click on the given element, with an optional offset. @@ -80,15 +80,15 @@ def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, Args: element (WebElement): The element to double-click on. This parameter is required. offset (Optional[Tuple[int, int]]): The x and y offsets from the element to click at. If not specified, the click is performed at the element's center. - + Returns: None: """ - opts: Dict[str, Union[WebElement, Dict[str, int]]] = {'origin': element} + opts: Dict[str, Union[WebElement, Dict[str, int]]] = {"origin": element} if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('doubleClick', opts) - + def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: """ Performs a long press on the given element, with an optional offset. @@ -96,15 +96,15 @@ def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, in Args: element (WebElement): The element to perform the long press on. This parameter is required. offset (Optional[Tuple[int, int]]): The x and y offsets from the element to perform the long press at. If not specified, the long press is performed at the element's center. - + Returns: None: """ - opts: Dict[str, Union[WebElement, Dict[str, int]]]= {'origin': element} + opts: Dict[str, Union[WebElement, Dict[str, int]]] = {'origin': element} if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('longPress', opts) - + def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: """ Performs a drag-and-drop operation from a source element to a target element. @@ -112,12 +112,12 @@ def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: Args: source (WebElement): The element to drag from. target (WebElement): The element to drop onto. - + Returns: None: """ self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) - + def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: ScrollDirection = ScrollDirection.DOWN, **opts: Any) -> WebElement: """ Scrolls until the specified element becomes visible. @@ -125,38 +125,38 @@ def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: Scroll Args: scroll_to (FlutterFinder): The Flutter element to scroll to. scroll_direction (ScrollDirection): The direction to scroll up or down. Defaults to `ScrollDirection.DOWN`. - + KeywordArgs: - scrollView (str): The view of the scroll. - delta (int): delta for the scroll - maxScrolls (int): Max times to scroll - settleBetweenScrollsTimeout (int): settle timeout - dragDuration (int): time gap between each scroll - + scrollView (str): The view of the scroll. Default value is 'Scrollable' + delta (int): delta for the scroll. Default value is 64 + maxScrolls (int): Max times to scroll. Default value is 15 + settleBetweenScrollsTimeout (int): settle timeout in milliseconds. Default value is 5000ms + dragDuration (int): time gap between each scroll in milliseconds. Default value is 100ms + Returns: Webelement: scrolled element """ opts['finder'] = scroll_to.to_dict() - opts['scrollDirection'] = scroll_direction.as_string() + opts['scrollDirection'] = scroll_direction.value return self.execute_flutter_command('scrollTillVisible', opts) - + def inject_mock_image(self, value: str) -> str: """ Injects a mock image to the device. The input can be a file path or a base64-encoded string. Args: value (str): The file path of the image or a base64-encoded string. - + Returns: str: Image ID of the injected image. """ - if os.path.isfile(value): + if os.path.isfile(value): with open(value, 'rb') as image_file: base64_encoded_image = base64.b64encode(image_file.read()).decode('utf-8') else: base64_encoded_image = value return self.execute_flutter_command('injectImage', {'base64Image': base64_encoded_image}) - + def activate_injected_image(self, image_id: str) -> None: """ Activates an injected image with image ID. @@ -168,18 +168,18 @@ def activate_injected_image(self, image_id: str) -> None: None: """ self.execute_flutter_command('activateInjectedImage', {'imageId': image_id}) - + def execute_flutter_command(self, scriptName: str, params: dict) -> Any: """ Executes a Flutter command by sending a script and parameters to the flutter integration driver. Args: - scriptName (str): The name of the Flutter command to execute. + scriptName (str): The name of the Flutter command to execute. This will be prefixed with 'flutter:' when passed to the driver. params (dict): A dictionary of parameters to be passed along with the Flutter command. Returns: - Any: The result of the command execution. The return value depends on the + Any: The result of the command execution. The return value depends on the specific Flutter command being executed. """ - return self.driver.execute_script(f'flutter: {scriptName}', params) \ No newline at end of file + return self.driver.execute_script(f'flutter: {scriptName}', params) diff --git a/appium/webdriver/extensions/flutter_integration/scroll_directions.py b/appium/webdriver/extensions/flutter_integration/scroll_directions.py index 2249a370..7624b5b2 100644 --- a/appium/webdriver/extensions/flutter_integration/scroll_directions.py +++ b/appium/webdriver/extensions/flutter_integration/scroll_directions.py @@ -1,8 +1,6 @@ from enum import Enum + class ScrollDirection(Enum): UP = 'up' DOWN = 'down' - - def as_string(self) -> str: - return str(self.value) \ No newline at end of file diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/flutter_finder.py index 044331f8..184560f5 100644 --- a/appium/webdriver/flutter_finder.py +++ b/appium/webdriver/flutter_finder.py @@ -17,35 +17,35 @@ from appium.webdriver.common.appiumby import AppiumBy + class FlutterFinder: - + def __init__(self, using: str, value: str) -> None: self.using = using self.value = value - + @staticmethod - def by_flutter_key(value: str) -> 'FlutterFinder': + def by_key(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_KEY, value) - + @staticmethod - def by_flutter_text(value: str) -> 'FlutterFinder': + def by_text(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT, value) - + @staticmethod - def by_flutter_semantics_label(value: str) -> 'FlutterFinder': + def by_semantics_label(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, value) - + @staticmethod - def by_flutter_type(value: str) -> 'FlutterFinder': + def by_type(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TYPE, value) - + @staticmethod - def by_flutter_text_containing(value: str) -> 'FlutterFinder': + def by_text_containing(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, value) - + def to_dict(self) -> dict: - return { 'using': self.using, 'value': self.value} - + return {'using': self.using, 'value': self.value} + def as_args(self) -> tuple[str, str]: return self.using, self.value - diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py index ebcce015..46e01868 100644 --- a/test/functional/flutter_integration/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -19,13 +19,14 @@ from appium.webdriver.flutter_finder import FlutterFinder from test.functional.flutter_integration.helper.test_helper import BaseTestCase + class TestFlutterCommands(BaseTestCase): def test_wait_command(self) -> None: self.__open_screen('Lazy Loading') - message_field_finder = FlutterFinder.by_flutter_key('message_field') - toggle_button_finder = FlutterFinder.by_flutter_key('toggle_button') + message_field_finder = FlutterFinder.by_key('message_field') + toggle_button_finder = FlutterFinder.by_key('toggle_button') message_field = self.driver.find_element(*message_field_finder.as_args()) toggle_button = self.driver.find_element(*toggle_button_finder.as_args()) @@ -43,8 +44,8 @@ def test_wait_command(self) -> None: def test_scroll_till_visible_command(self) -> None: self.__open_screen('Vertical Swiping') - java_text_finder = FlutterFinder.by_flutter_text('Java') - protractor_text_finder = FlutterFinder.by_flutter_text('Protractor') + java_text_finder = FlutterFinder.by_text('Java') + protractor_text_finder = FlutterFinder.by_text('Protractor') first_element = self.flutter_command.scroll_till_visible(java_text_finder) assert first_element.get_attribute('displayed') == 'true' @@ -60,13 +61,13 @@ def test_scroll_till_visible_command(self) -> None: def test_scroll_till_visible_with_scroll_params_command(self) -> None: self.__open_screen('Vertical Swiping') - scroll_params = {'scrollView': FlutterFinder.by_flutter_type('Scrollable').to_dict(), + scroll_params = {'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), 'delta': 30, 'maxScrolls': 30, 'settleBetweenScrollsTimeout': 5000, 'dragDuration': 35 } - first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params) + first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params) assert first_element.get_attribute('displayed') == 'true' def test_double_click_command(self) -> None: @@ -121,6 +122,6 @@ def test_camera_mocking(self) -> None: def __open_screen(self, screen_name: str) -> None: self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Login').click() - element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text(screen_name)) + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text(screen_name)) element.click() diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py index ce688f61..7137a01a 100644 --- a/test/functional/flutter_integration/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -16,12 +16,13 @@ from appium.webdriver.flutter_finder import FlutterFinder from test.functional.flutter_integration.helper.test_helper import BaseTestCase -LOGIN_BUTTON_FINDER = FlutterFinder.by_flutter_text('Login') + +LOGIN_BUTTON_FINDER = FlutterFinder.by_text('Login') class TestFlutterFinders(BaseTestCase): def test_by_flutter_key(self) -> None: - user_name_field_finder = FlutterFinder.by_flutter_key('username_text_field') + user_name_field_finder = FlutterFinder.by_key('username_text_field') user_name_field = self.driver.find_element(*user_name_field_finder.as_args()) assert user_name_field.text == 'admin' @@ -50,7 +51,7 @@ def test_by_flutter_text_containing(self) -> None: def test_by_flutter_semantics_label(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() - element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Lazy Loading')) + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Lazy Loading')) element.click() message_field = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field') assert message_field.text == 'Hello world' \ No newline at end of file diff --git a/test/functional/flutter_integration/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py index ade0d71b..580a287e 100644 --- a/test/functional/flutter_integration/helper/desired_capabilities.py +++ b/test/functional/flutter_integration/helper/desired_capabilities.py @@ -15,6 +15,7 @@ import os from typing import Any, Dict + def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: desired_caps: Dict[str, Any] = {} if platform_name == 'android': diff --git a/test/functional/flutter_integration/helper/test_helper.py b/test/functional/flutter_integration/helper/test_helper.py index 62761f09..1cc8e364 100644 --- a/test/functional/flutter_integration/helper/test_helper.py +++ b/test/functional/flutter_integration/helper/test_helper.py @@ -27,15 +27,15 @@ class BaseTestCase(object): def setup_method(self) -> None: - platform_name = os.getenv('PLATFORM', 'android').lower() - + platform_name = os.getenv('PLATFORM', 'android').lower() + # set flutter options flutterOptions = FlutterOptions() flutterOptions.flutter_system_port = 9999 flutterOptions.flutter_enable_mock_camera = True flutterOptions.flutter_element_wait_timeout = 10000 flutterOptions.flutter_server_launch_timeout = 120000 - + desired_caps = desired_capabilities.get_desired_capabilities(platform_name) self.driver = webdriver.Remote(SERVER_URL_BASE, options=flutterOptions.load_capabilities(desired_caps)) self.flutter_command = FlutterCommand(self.driver) @@ -44,4 +44,3 @@ def teardown_method(self) -> None: # type: ignore if not hasattr(self, 'driver'): return self.driver.quit() - From 3c783aeb2eb9633cb257d3ce60565b5d5a28496f Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 00:07:02 +0200 Subject: [PATCH 06/21] feat: Add unit tests and fix review comments --- appium/common/helper.py | 6 + .../options/flutter_integration/__init__.py | 2 +- appium/options/flutter_integration/base.py | 6 +- .../flutter_element_wait_timeout_option.py | 12 +- .../flutter_enable_mock_camera_option.py | 10 +- .../flutter_server_launch_timeout_option.py | 12 +- .../flutter_system_port_option.py | 8 +- appium/webdriver/common/appiumby.py | 2 + .../flutter_integration/flutter_commands.py | 94 ++++---- .../flutter_integration/scroll_directions.py | 4 +- appium/webdriver/flutter_finder.py | 30 +-- .../flutter_integration/commands_test.py | 15 +- .../flutter_integration/finder_test.py | 7 +- .../helper/desired_capabilities.py | 1 + .../flutter_integration/helper/test_helper.py | 7 +- test/unit/helper/test_helper.py | 48 +++- .../flutter_integration/file/success_qr.png | Bin 0 -> 32757 bytes .../flutter_actions_test.py | 157 +++++++++++++ .../flutter_integration_driver_test.py | 61 +++++ .../flutter_search_context_test.py | 212 ++++++++++++++++++ .../flutter_integration/flutter_waits_test.py | 105 +++++++++ 21 files changed, 692 insertions(+), 107 deletions(-) create mode 100644 test/unit/webdriver/flutter_integration/file/success_qr.png create mode 100644 test/unit/webdriver/flutter_integration/flutter_actions_test.py create mode 100644 test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py create mode 100644 test/unit/webdriver/flutter_integration/flutter_search_context_test.py create mode 100644 test/unit/webdriver/flutter_integration/flutter_waits_test.py diff --git a/appium/common/helper.py b/appium/common/helper.py index 874c453f..b78d499f 100644 --- a/appium/common/helper.py +++ b/appium/common/helper.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 from typing import Any, Dict from appium import version as appium_version @@ -33,3 +34,8 @@ def library_version() -> str: """Return a version of this python library""" return appium_version.version + +def encode_file_to_base64(file_path: str) -> str: + """Return base64 encoded string for given file""" + with open(file_path, 'rb') as file: + return base64.b64encode(file.read()).decode('utf-8') diff --git a/appium/options/flutter_integration/__init__.py b/appium/options/flutter_integration/__init__.py index 3e9808cd..865d653e 100644 --- a/appium/options/flutter_integration/__init__.py +++ b/appium/options/flutter_integration/__init__.py @@ -1 +1 @@ -from .base import FlutterOptions \ No newline at end of file +from .base import FlutterOptions diff --git a/appium/options/flutter_integration/base.py b/appium/options/flutter_integration/base.py index 7cd36a12..65d1c19a 100644 --- a/appium/options/flutter_integration/base.py +++ b/appium/options/flutter_integration/base.py @@ -16,6 +16,7 @@ # under the License. from typing import Dict + from appium.options.common.automation_name_option import AUTOMATION_NAME from appium.options.common.base import AppiumOptions from appium.options.flutter_integration.flutter_element_wait_timeout_option import FlutterElementWaitTimeOutOption @@ -29,12 +30,11 @@ class FlutterOptions( FlutterElementWaitTimeOutOption, FlutterEnableMockCameraOption, FlutterServerLaunchTimeOutOption, - FlutterSystemPortOption + FlutterSystemPortOption, ): - + @property def default_capabilities(self) -> Dict: return { AUTOMATION_NAME: 'FlutterIntegration', } - \ No newline at end of file diff --git a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py index c8c38a21..ee2932c4 100644 --- a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py +++ b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py @@ -17,19 +17,19 @@ from datetime import timedelta from typing import Optional, Union -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities -FLUTTER_ELEMENT_WAIT_TIMEOUT= 'flutterElementWaitTimeout' +FLUTTER_ELEMENT_WAIT_TIMEOUT = 'flutterElementWaitTimeout' class FlutterElementWaitTimeOutOption(SupportsCapabilities): - + @property def flutter_element_wait_timeout(self) -> Optional[timedelta]: """ Maximum timeout to wait for element for Flutter integration test - + Returns: Optional[timedelta]: The timeout value as a `timedelta` object if set, or `None` if the timeout is not defined. """ @@ -46,6 +46,6 @@ def flutter_element_wait_timeout(self, value: Union[timedelta, int]) -> None: If provided as a `timedelta`, it will be converted to milliseconds. """ self.set_capability( - FLUTTER_ELEMENT_WAIT_TIMEOUT, + FLUTTER_ELEMENT_WAIT_TIMEOUT, int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value - ) \ No newline at end of file + ) diff --git a/appium/options/flutter_integration/flutter_enable_mock_camera_option.py b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py index cc6abaa9..7b335b25 100644 --- a/appium/options/flutter_integration/flutter_enable_mock_camera_option.py +++ b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py @@ -16,19 +16,19 @@ # under the License. from typing import Optional -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities FLUTTER_ENABLE_MOCK_CAMERA = 'flutterEnableMockCamera' class FlutterEnableMockCameraOption(SupportsCapabilities): - + @property def flutter_enable_mock_camera(self) -> bool: """ Get state of the mock camera for Flutter integration test - + Returns: bool: A boolean indicating whether the mock camera is enabled (True) or disabled (False). """ @@ -36,11 +36,11 @@ def flutter_enable_mock_camera(self) -> bool: @flutter_enable_mock_camera.setter def flutter_enable_mock_camera(self, value: bool) -> None: - """ + """ Setter method enable or disable the mock camera for Flutter integration test Default state is `False` Args: value (bool): A boolean value indicating whether to enable (True) or disable (False) the mock camera. """ - self.set_capability(FLUTTER_ENABLE_MOCK_CAMERA, value) \ No newline at end of file + self.set_capability(FLUTTER_ENABLE_MOCK_CAMERA, value) diff --git a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py index 9e1476ec..c09532e0 100644 --- a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py +++ b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py @@ -17,14 +17,14 @@ from datetime import timedelta from typing import Optional, Union -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities -FLUTTER_SERVER_LAUNCH_TIMEOUT= 'flutterServerLaunchTimeout' +FLUTTER_SERVER_LAUNCH_TIMEOUT = 'flutterServerLaunchTimeout' class FlutterServerLaunchTimeOutOption(SupportsCapabilities): - + @property def flutter_server_launch_timeout(self) -> Optional[timedelta]: """ @@ -43,10 +43,10 @@ def flutter_server_launch_timeout(self, value: Union[timedelta, int]) -> None: Default timeout is 5000ms Args: - value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. + value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. If provided as a `timedelta`, it will be converted to milliseconds. """ self.set_capability( - FLUTTER_SERVER_LAUNCH_TIMEOUT, + FLUTTER_SERVER_LAUNCH_TIMEOUT, int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value - ) \ No newline at end of file + ) diff --git a/appium/options/flutter_integration/flutter_system_port_option.py b/appium/options/flutter_integration/flutter_system_port_option.py index 13574ea4..2e049dd7 100644 --- a/appium/options/flutter_integration/flutter_system_port_option.py +++ b/appium/options/flutter_integration/flutter_system_port_option.py @@ -16,19 +16,19 @@ # under the License. from typing import Optional -from appium.options.common.supports_capabilities import SupportsCapabilities +from appium.options.common.supports_capabilities import SupportsCapabilities FLUTTER_SYSTEM_PORT = 'flutterSystemPort' class FlutterSystemPortOption(SupportsCapabilities): - + @property def flutter_system_port(self) -> Optional[int]: """ Get flutter system port for Flutter integration tests. - + Returns: int: returns the port number """ @@ -43,4 +43,4 @@ def flutter_system_port(self, value: int) -> None: Args: value (int): The port number to be used for the Flutter server. """ - self.set_capability(FLUTTER_SYSTEM_PORT, value) \ No newline at end of file + self.set_capability(FLUTTER_SYSTEM_PORT, value) diff --git a/appium/webdriver/common/appiumby.py b/appium/webdriver/common/appiumby.py index d5118c8c..162159e5 100644 --- a/appium/webdriver/common/appiumby.py +++ b/appium/webdriver/common/appiumby.py @@ -25,6 +25,8 @@ class AppiumBy(By): ACCESSIBILITY_ID = 'accessibility id' IMAGE = '-image' CUSTOM = '-custom' + + # For Flutter integration usage https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/tree/main FLUTTER_INTEGRATION_SEMANTICS_LABEL = '-flutter semantics label' FLUTTER_INTEGRATION_TYPE = '-flutter type' FLUTTER_INTEGRATION_KEY = '-flutter key' diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 7181e5f8..fd2f71aa 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -12,9 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import base64 import os from typing import Any, Dict, Optional, Tuple, Union + +from appium.common.helper import encode_file_to_base64 from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.flutter_finder import FlutterFinder from appium.webdriver.webdriver import WebDriver @@ -22,12 +23,12 @@ class FlutterCommand: - + def __init__(self, driver: WebDriver) -> None: self.driver = driver - - # wait commands - + + # wait commands + def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: """ Waits for a element to become visible. @@ -35,21 +36,16 @@ def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Args: locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. - - Returns: - None: + + Returns: + None: """ - opts: Dict[str, Union[WebElement, Dict[str, str], float]] = {} - if isinstance(locator, WebElement): - opts['element'] = locator - else: - opts['locator'] = locator.to_dict() + opts: Dict[str, Any] = self.__get_locator_options(locator) if time_out is not None: opts['timeout'] = time_out self.execute_flutter_command('waitForVisible', opts) - def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: """ Waits for a element to become invisible. @@ -57,22 +53,18 @@ def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out Args: locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. - + Returns: - None: + None: """ - opts: Dict[str, Union[WebElement, Dict[str, str], float]] = {} - if isinstance(locator, WebElement): - opts['element'] = locator - else: - opts['locator'] = locator.to_dict() + opts: Dict[str, Any] = self.__get_locator_options(locator) if time_out is not None: opts['timeout'] = time_out - + self.execute_flutter_command('waitForAbsent', opts) # flutter action commands - + def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: """ Performs a double-click on the given element, with an optional offset. @@ -80,15 +72,15 @@ def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, Args: element (WebElement): The element to double-click on. This parameter is required. offset (Optional[Tuple[int, int]]): The x and y offsets from the element to click at. If not specified, the click is performed at the element's center. - + Returns: None: """ - opts: Dict[str, Union[WebElement, Dict[str, int]]] = {'origin': element} + opts: Dict[str, Union[WebElement, Dict[str, int]]] = {"origin": element} if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('doubleClick', opts) - + def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: """ Performs a long press on the given element, with an optional offset. @@ -96,15 +88,15 @@ def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, in Args: element (WebElement): The element to perform the long press on. This parameter is required. offset (Optional[Tuple[int, int]]): The x and y offsets from the element to perform the long press at. If not specified, the long press is performed at the element's center. - + Returns: None: """ - opts: Dict[str, Union[WebElement, Dict[str, int]]]= {'origin': element} + opts: Dict[str, Union[WebElement, Dict[str, int]]] = {'origin': element} if offset is not None: opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('longPress', opts) - + def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: """ Performs a drag-and-drop operation from a source element to a target element. @@ -112,12 +104,12 @@ def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: Args: source (WebElement): The element to drag from. target (WebElement): The element to drop onto. - + Returns: None: """ self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) - + def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: ScrollDirection = ScrollDirection.DOWN, **opts: Any) -> WebElement: """ Scrolls until the specified element becomes visible. @@ -125,38 +117,37 @@ def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: Scroll Args: scroll_to (FlutterFinder): The Flutter element to scroll to. scroll_direction (ScrollDirection): The direction to scroll up or down. Defaults to `ScrollDirection.DOWN`. - + KeywordArgs: - scrollView (str): The view of the scroll. - delta (int): delta for the scroll - maxScrolls (int): Max times to scroll - settleBetweenScrollsTimeout (int): settle timeout - dragDuration (int): time gap between each scroll - + scrollView (str): The view of the scroll. Default value is 'Scrollable' + delta (int): delta for the scroll. Default value is 64 + maxScrolls (int): Max times to scroll. Default value is 15 + settleBetweenScrollsTimeout (int): settle timeout in milliseconds. Default value is 5000ms + dragDuration (int): time gap between each scroll in milliseconds. Default value is 100ms + Returns: Webelement: scrolled element """ opts['finder'] = scroll_to.to_dict() - opts['scrollDirection'] = scroll_direction.as_string() + opts['scrollDirection'] = scroll_direction.value return self.execute_flutter_command('scrollTillVisible', opts) - + def inject_mock_image(self, value: str) -> str: """ Injects a mock image to the device. The input can be a file path or a base64-encoded string. Args: value (str): The file path of the image or a base64-encoded string. - + Returns: str: Image ID of the injected image. """ - if os.path.isfile(value): - with open(value, 'rb') as image_file: - base64_encoded_image = base64.b64encode(image_file.read()).decode('utf-8') + if os.path.isfile(value): + base64_encoded_image = encode_file_to_base64(value) else: base64_encoded_image = value return self.execute_flutter_command('injectImage', {'base64Image': base64_encoded_image}) - + def activate_injected_image(self, image_id: str) -> None: """ Activates an injected image with image ID. @@ -168,18 +159,23 @@ def activate_injected_image(self, image_id: str) -> None: None: """ self.execute_flutter_command('activateInjectedImage', {'imageId': image_id}) - + def execute_flutter_command(self, scriptName: str, params: dict) -> Any: """ Executes a Flutter command by sending a script and parameters to the flutter integration driver. Args: - scriptName (str): The name of the Flutter command to execute. + scriptName (str): The name of the Flutter command to execute. This will be prefixed with 'flutter:' when passed to the driver. params (dict): A dictionary of parameters to be passed along with the Flutter command. Returns: - Any: The result of the command execution. The return value depends on the + Any: The result of the command execution. The return value depends on the specific Flutter command being executed. """ - return self.driver.execute_script(f'flutter: {scriptName}', params) \ No newline at end of file + return self.driver.execute_script(f'flutter: {scriptName}', params) + + def __get_locator_options(self, locator: Union[WebElement, 'FlutterFinder']) -> Dict[str, dict]: + if isinstance(locator, WebElement): + return {'element': locator} + return {'locator': locator.to_dict()} diff --git a/appium/webdriver/extensions/flutter_integration/scroll_directions.py b/appium/webdriver/extensions/flutter_integration/scroll_directions.py index 2249a370..7624b5b2 100644 --- a/appium/webdriver/extensions/flutter_integration/scroll_directions.py +++ b/appium/webdriver/extensions/flutter_integration/scroll_directions.py @@ -1,8 +1,6 @@ from enum import Enum + class ScrollDirection(Enum): UP = 'up' DOWN = 'down' - - def as_string(self) -> str: - return str(self.value) \ No newline at end of file diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/flutter_finder.py index 044331f8..184560f5 100644 --- a/appium/webdriver/flutter_finder.py +++ b/appium/webdriver/flutter_finder.py @@ -17,35 +17,35 @@ from appium.webdriver.common.appiumby import AppiumBy + class FlutterFinder: - + def __init__(self, using: str, value: str) -> None: self.using = using self.value = value - + @staticmethod - def by_flutter_key(value: str) -> 'FlutterFinder': + def by_key(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_KEY, value) - + @staticmethod - def by_flutter_text(value: str) -> 'FlutterFinder': + def by_text(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT, value) - + @staticmethod - def by_flutter_semantics_label(value: str) -> 'FlutterFinder': + def by_semantics_label(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, value) - + @staticmethod - def by_flutter_type(value: str) -> 'FlutterFinder': + def by_type(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TYPE, value) - + @staticmethod - def by_flutter_text_containing(value: str) -> 'FlutterFinder': + def by_text_containing(value: str) -> 'FlutterFinder': return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, value) - + def to_dict(self) -> dict: - return { 'using': self.using, 'value': self.value} - + return {'using': self.using, 'value': self.value} + def as_args(self) -> tuple[str, str]: return self.using, self.value - diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py index ebcce015..46e01868 100644 --- a/test/functional/flutter_integration/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -19,13 +19,14 @@ from appium.webdriver.flutter_finder import FlutterFinder from test.functional.flutter_integration.helper.test_helper import BaseTestCase + class TestFlutterCommands(BaseTestCase): def test_wait_command(self) -> None: self.__open_screen('Lazy Loading') - message_field_finder = FlutterFinder.by_flutter_key('message_field') - toggle_button_finder = FlutterFinder.by_flutter_key('toggle_button') + message_field_finder = FlutterFinder.by_key('message_field') + toggle_button_finder = FlutterFinder.by_key('toggle_button') message_field = self.driver.find_element(*message_field_finder.as_args()) toggle_button = self.driver.find_element(*toggle_button_finder.as_args()) @@ -43,8 +44,8 @@ def test_wait_command(self) -> None: def test_scroll_till_visible_command(self) -> None: self.__open_screen('Vertical Swiping') - java_text_finder = FlutterFinder.by_flutter_text('Java') - protractor_text_finder = FlutterFinder.by_flutter_text('Protractor') + java_text_finder = FlutterFinder.by_text('Java') + protractor_text_finder = FlutterFinder.by_text('Protractor') first_element = self.flutter_command.scroll_till_visible(java_text_finder) assert first_element.get_attribute('displayed') == 'true' @@ -60,13 +61,13 @@ def test_scroll_till_visible_command(self) -> None: def test_scroll_till_visible_with_scroll_params_command(self) -> None: self.__open_screen('Vertical Swiping') - scroll_params = {'scrollView': FlutterFinder.by_flutter_type('Scrollable').to_dict(), + scroll_params = {'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), 'delta': 30, 'maxScrolls': 30, 'settleBetweenScrollsTimeout': 5000, 'dragDuration': 35 } - first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params) + first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params) assert first_element.get_attribute('displayed') == 'true' def test_double_click_command(self) -> None: @@ -121,6 +122,6 @@ def test_camera_mocking(self) -> None: def __open_screen(self, screen_name: str) -> None: self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Login').click() - element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text(screen_name)) + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text(screen_name)) element.click() diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py index ce688f61..7137a01a 100644 --- a/test/functional/flutter_integration/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -16,12 +16,13 @@ from appium.webdriver.flutter_finder import FlutterFinder from test.functional.flutter_integration.helper.test_helper import BaseTestCase -LOGIN_BUTTON_FINDER = FlutterFinder.by_flutter_text('Login') + +LOGIN_BUTTON_FINDER = FlutterFinder.by_text('Login') class TestFlutterFinders(BaseTestCase): def test_by_flutter_key(self) -> None: - user_name_field_finder = FlutterFinder.by_flutter_key('username_text_field') + user_name_field_finder = FlutterFinder.by_key('username_text_field') user_name_field = self.driver.find_element(*user_name_field_finder.as_args()) assert user_name_field.text == 'admin' @@ -50,7 +51,7 @@ def test_by_flutter_text_containing(self) -> None: def test_by_flutter_semantics_label(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() - element = self.flutter_command.scroll_till_visible(FlutterFinder.by_flutter_text('Lazy Loading')) + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Lazy Loading')) element.click() message_field = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field') assert message_field.text == 'Hello world' \ No newline at end of file diff --git a/test/functional/flutter_integration/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py index ade0d71b..580a287e 100644 --- a/test/functional/flutter_integration/helper/desired_capabilities.py +++ b/test/functional/flutter_integration/helper/desired_capabilities.py @@ -15,6 +15,7 @@ import os from typing import Any, Dict + def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: desired_caps: Dict[str, Any] = {} if platform_name == 'android': diff --git a/test/functional/flutter_integration/helper/test_helper.py b/test/functional/flutter_integration/helper/test_helper.py index 62761f09..1cc8e364 100644 --- a/test/functional/flutter_integration/helper/test_helper.py +++ b/test/functional/flutter_integration/helper/test_helper.py @@ -27,15 +27,15 @@ class BaseTestCase(object): def setup_method(self) -> None: - platform_name = os.getenv('PLATFORM', 'android').lower() - + platform_name = os.getenv('PLATFORM', 'android').lower() + # set flutter options flutterOptions = FlutterOptions() flutterOptions.flutter_system_port = 9999 flutterOptions.flutter_enable_mock_camera = True flutterOptions.flutter_element_wait_timeout = 10000 flutterOptions.flutter_server_launch_timeout = 120000 - + desired_caps = desired_capabilities.get_desired_capabilities(platform_name) self.driver = webdriver.Remote(SERVER_URL_BASE, options=flutterOptions.load_capabilities(desired_caps)) self.flutter_command = FlutterCommand(self.driver) @@ -44,4 +44,3 @@ def teardown_method(self) -> None: # type: ignore if not hasattr(self, 'driver'): return self.driver.quit() - diff --git a/test/unit/helper/test_helper.py b/test/unit/helper/test_helper.py index 7037757e..f1768d48 100644 --- a/test/unit/helper/test_helper.py +++ b/test/unit/helper/test_helper.py @@ -117,7 +117,6 @@ def ios_w3c_driver() -> 'WebDriver': driver = webdriver.Remote(SERVER_URL_BASE, options=XCUITestOptions().load_capabilities(desired_caps)) return driver - def ios_w3c_driver_with_extensions(extensions) -> 'WebDriver': """Return a W3C driver which is generated by a mock response for iOS @@ -151,6 +150,53 @@ def ios_w3c_driver_with_extensions(extensions) -> 'WebDriver': ) return driver +def flutter_w3c_driver() -> 'WebDriver': + """Return a W3C driver which is generated by a mock response for Flutter + + Returns: + `webdriver.webdriver.WebDriver`: An instance of WebDriver + """ + + response_body_json = json.dumps( + { + 'sessionId': '1234567890', + 'capabilities': { + 'platform': 'LINUX', + 'desired': { + 'platformName': 'Android', + 'autoGrantPermissions': True, + 'flutterSystemPort': 9999, + 'flutterElementWaitTimeout': 10000, + 'flutterEnableMockCamera': True, + 'flutterServerLaunchTimeout': 120000, + 'automationName': 'FlutterIntegration', + 'platformVersion': '7.1.1', + 'deviceName': 'Android Emulator', + 'app': '/test/apps/ApiDemos-debug.apk', + }, + 'platformName': 'Android', + 'automationName': 'FlutterIntegration', + 'platformVersion': '7.1.1', + 'deviceName': 'emulator-5554', + 'app': '/test/apps/ApiDemos-debug.apk', + 'deviceUDID': 'emulator-5554', + 'appPackage': 'io.appium.android.apis', + 'appWaitPackage': 'io.appium.android.apis', + }, + } + ) + + httpretty.register_uri(httpretty.POST, appium_command('/session'), body=response_body_json) + + desired_caps = { + 'platformName': 'Android', + 'deviceName': 'Android Emulator', + 'app': 'path/to/app', + 'automationName': 'FlutterIntegration', + } + + driver = webdriver.Remote(SERVER_URL_BASE, options=UiAutomator2Options().load_capabilities(desired_caps)) + return driver def get_httpretty_request_body(request: 'HTTPrettyRequestEmpty') -> Dict[str, Any]: """Returns utf-8 decoded request body""" diff --git a/test/unit/webdriver/flutter_integration/file/success_qr.png b/test/unit/webdriver/flutter_integration/file/success_qr.png new file mode 100644 index 0000000000000000000000000000000000000000..8896d86f603407e5bbdc7c4411fb03bbae15da51 GIT binary patch literal 32757 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 S^YP!2q$&#P^0n{GLjDhG!;-iF literal 0 HcmV?d00001 diff --git a/test/unit/webdriver/flutter_integration/flutter_actions_test.py b/test/unit/webdriver/flutter_integration/flutter_actions_test.py new file mode 100644 index 00000000..730529d5 --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_actions_test.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import httpretty + +from appium.common.helper import encode_file_to_base64 +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection +from appium.webdriver.webelement import WebElement as MobileWebElement +from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand +from appium.webdriver.flutter_finder import FlutterFinder +from test.unit.helper.test_helper import flutter_w3c_driver, appium_command, get_httpretty_request_body + + +class TestFlutterActions(object): + + @httpretty.activate + def test_double_click(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + element = MobileWebElement(driver, 'element_id') + flutter.perform_double_click(element, (10,20)) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: doubleClick' + assert list(arguments['origin'].values())[0] == 'element_id' + assert arguments['offset'] == {'x': 10, 'y': 20} + + @httpretty.activate + def test_drag_and_drop(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + drag_element = MobileWebElement(driver, 'element_id1') + drop_element = MobileWebElement(driver, 'element_id2') + flutter.perform_drag_and_drop(drag_element, drop_element) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: dragAndDrop' + assert list(arguments['source'].values())[0] == 'element_id1' + assert list(arguments['target'].values())[0] == 'element_id2' + + @httpretty.activate + def test_scroll_till_visible(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + finder = FlutterFinder.by_key('message_field') + flutter.scroll_till_visible(finder) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + expected_arguments = { + 'finder': {'using': '-flutter key', 'value': 'message_field'}, + 'scrollDirection': 'down' + } + assert request_body['script'] == 'flutter: scrollTillVisible' + assert arguments == expected_arguments + + @httpretty.activate + def test_scroll_till_visible_with_params(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + finder = FlutterFinder.by_key('message_field') + scroll_params = {'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), + 'delta': 30, + 'maxScrolls': 30, + 'settleBetweenScrollsTimeout': 5000, + 'dragDuration': 35 + } + flutter.scroll_till_visible(finder, ScrollDirection.UP, **scroll_params) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: scrollTillVisible' + expected_arguments = { + 'finder': {'using': '-flutter key', 'value': 'message_field'}, + 'scrollView': {'using': '-flutter type', 'value': 'Scrollable'}, + 'scrollDirection': 'up', + 'dragDuration': 35, + 'settleBetweenScrollsTimeout': 5000, + 'maxScrolls': 30, + 'delta': 30 + } + assert arguments == expected_arguments + + @httpretty.activate + def test_inject_mock_image_with_file(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') + base64_encoded_image = encode_file_to_base64(success_qr_file_path) + flutter.inject_mock_image(success_qr_file_path) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: injectImage' + assert arguments == {'base64Image': base64_encoded_image} + + @httpretty.activate + def test_activate_injected_image(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + flutter.activate_injected_image('213476478') + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: activateInjectedImage' + assert arguments == {'imageId': '213476478'} + diff --git a/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py b/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py new file mode 100644 index 00000000..1640e5c7 --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import httpretty + +from appium import webdriver +from appium.options.flutter_integration.base import FlutterOptions +from test.helpers.constants import SERVER_URL_BASE + + +class TestFlutterIntegrationDriver: + + @httpretty.activate + def test_create_session(self): + + # Set flutter options + flutterOptions = FlutterOptions() + flutterOptions.flutter_system_port = 9999 + flutterOptions.flutter_enable_mock_camera = True + flutterOptions.flutter_element_wait_timeout = 10000 + flutterOptions.flutter_server_launch_timeout = 120000 + + httpretty.register_uri( + httpretty.POST, + f'{SERVER_URL_BASE}/session', + body='{ "value": {"sessionId": "session-id", "capabilities": {"deviceName": "Android Emulator"}} }', + ) + + desired_caps = { + 'platformName': 'Android', + 'deviceName': 'Android Emulator', + 'app': 'path/to/app', + } + driver = webdriver.Remote(SERVER_URL_BASE, options=flutterOptions.load_capabilities(desired_caps)) + + request_json = json.loads(httpretty.HTTPretty.latest_requests[0].body.decode('utf-8')) + assert request_json.get('capabilities') is not None + assert request_json['capabilities']['alwaysMatch'] == { + 'platformName': 'Android', + 'appium:deviceName': 'Android Emulator', + 'appium:app': 'path/to/app', + 'appium:automationName': 'FlutterIntegration', + 'appium:flutterSystemPort': 9999, + 'appium:flutterEnableMockCamera': True, + 'appium:flutterElementWaitTimeout': 10000, + 'appium:flutterServerLaunchTimeout': 120000, + } + assert request_json.get('desiredCapabilities') is None + assert driver.session_id == 'session-id' \ No newline at end of file diff --git a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py new file mode 100644 index 00000000..3d481138 --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import httpretty + +from appium.webdriver.common.appiumby import AppiumBy +from test.unit.helper.test_helper import flutter_w3c_driver, appium_command, get_httpretty_request_body + + +class TestFlutterSearchContext(object): + + @httpretty.activate + def test_find_element_by_flutter_key(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element( + by = AppiumBy.FLUTTER_INTEGRATION_KEY, + value= 'Flutter UI Key', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter key' + assert d['value'] == 'Flutter UI Key' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_key(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements( + by = AppiumBy.FLUTTER_INTEGRATION_KEY, + value= 'Flutter UI Key', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter key' + assert d['value'] == 'Flutter UI Key' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + + @httpretty.activate + def test_find_element_by_flutter_text(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element( + by = AppiumBy.FLUTTER_INTEGRATION_TEXT, + value = 'Flutter UI Text', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text' + assert d['value'] == 'Flutter UI Text' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_text(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements( + by = AppiumBy.FLUTTER_INTEGRATION_TEXT, + value= 'Flutter UI Text', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text' + assert d['value'] == 'Flutter UI Text' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + @httpretty.activate + def test_find_element_by_flutter_semantics_label(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element( + by = AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, + value = 'Flutter UI Semantics Label', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter semantics label' + assert d['value'] == 'Flutter UI Semantics Label' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_semantics_label(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements( + by = AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, + value= 'Flutter UI Semantics Label', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter semantics label' + assert d['value'] == 'Flutter UI Semantics Label' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + @httpretty.activate + def test_find_element_by_flutter_type(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element( + by = AppiumBy.FLUTTER_INTEGRATION_TYPE, + value = 'Flutter UI Type', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter type' + assert d['value'] == 'Flutter UI Type' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_type(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements( + by = AppiumBy.FLUTTER_INTEGRATION_TYPE, + value= 'Flutter UI Type', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter type' + assert d['value'] == 'Flutter UI Type' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + @httpretty.activate + def test_find_element_by_flutter_text_containing(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element( + by = AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, + value = 'Flutter UI Partial Text', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text containing' + assert d['value'] == 'Flutter UI Partial Text' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_text_containing(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements( + by = AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, + value= 'Flutter UI Partial Text', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text containing' + assert d['value'] == 'Flutter UI Partial Text' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' \ No newline at end of file diff --git a/test/unit/webdriver/flutter_integration/flutter_waits_test.py b/test/unit/webdriver/flutter_integration/flutter_waits_test.py new file mode 100644 index 00000000..af5329fe --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_waits_test.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import httpretty + +from appium.webdriver.webelement import WebElement as MobileWebElement +from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand +from appium.webdriver.flutter_finder import FlutterFinder +from test.unit.helper.test_helper import flutter_w3c_driver, appium_command, get_httpretty_request_body + + +class TestFlutterWaits(object): + + @httpretty.activate + def test_wait_for_visible_with_finder(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + finder = FlutterFinder.by_key('message_field') + flutter.wait_for_visible(finder, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForVisible' + expected_arguments = { + 'locator': {'using': '-flutter key', 'value': 'message_field'}, + 'timeout': 5 + } + assert arguments == expected_arguments + + @httpretty.activate + def test_wait_for_visible_with_webelement(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + element = MobileWebElement(driver, 'element_id') + flutter.wait_for_visible(element, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForVisible' + assert list(arguments['element'].values())[0] == 'element_id' + assert arguments['timeout'] == 5 + + @httpretty.activate + def test_wait_for_invisible_with_finder(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + message_field_finder = FlutterFinder.by_key('message_field') + flutter.wait_for_invisible(message_field_finder, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForAbsent' + expected_arguments = { + 'locator': {'using': '-flutter key', 'value': 'message_field'}, + 'timeout': 5 + } + assert arguments == expected_arguments + + @httpretty.activate + def test_wait_for_invisible_with_webelement(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + element = MobileWebElement(driver, 'element_id') + flutter.wait_for_invisible(element, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForAbsent' + assert list(arguments['element'].values())[0] == 'element_id' + assert arguments['timeout'] == 5 \ No newline at end of file From 58821c2764017488ed3a2a7236decef7c988d9b5 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 20:15:27 +0200 Subject: [PATCH 07/21] feat: fix flutter e2e tests --- .github/workflows/functional-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 96ebae38..97d11777 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -330,4 +330,4 @@ jobs: pipenv lock --clear pipenv install -d --system export PLATFORM=ios - pytest test/functional/flutter/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html + pytest test/functional/flutter_integration/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html From caeb300a9f34591f9dad9b70a45d7ad0da5d7fe2 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 20:48:04 +0200 Subject: [PATCH 08/21] feat: fix tests in CI --- .github/workflows/functional-test.yml | 2 +- appium/webdriver/flutter_finder.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 97d11777..bc675c14 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -290,7 +290,7 @@ jobs: pipenv lock --clear pipenv install -d --system export PLATFORM=android - pytest test/functional/flutter/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html + pytest test/functional/flutter_integration/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html target: google_apis profile: Nexus 5X disable-spellchecker: true diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/flutter_finder.py index 184560f5..2c120137 100644 --- a/appium/webdriver/flutter_finder.py +++ b/appium/webdriver/flutter_finder.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +from typing import Tuple from appium.webdriver.common.appiumby import AppiumBy @@ -47,5 +48,5 @@ def by_text_containing(value: str) -> 'FlutterFinder': def to_dict(self) -> dict: return {'using': self.using, 'value': self.value} - def as_args(self) -> tuple[str, str]: + def as_args(self) -> Tuple[str, str]: return self.using, self.value From 3589222099bb2d73fa36646a4f4bf50572e63db6 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 21:52:40 +0200 Subject: [PATCH 09/21] feat: fix CI issues and formatting --- .github/workflows/functional-test.yml | 2 +- appium/common/helper.py | 1 + appium/webdriver/common/appiumby.py | 4 +- appium/webdriver/flutter_finder.py | 1 + .../flutter_integration/finder_test.py | 14 ++-- .../helper/desired_capabilities.py | 40 +++++----- test/unit/helper/test_helper.py | 3 + .../flutter_actions_test.py | 57 ++++++++------- .../flutter_integration_driver_test.py | 11 +-- .../flutter_search_context_test.py | 73 ++++++------------- .../flutter_integration/flutter_waits_test.py | 32 ++++---- 11 files changed, 109 insertions(+), 129 deletions(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index bc675c14..47f87de7 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -247,7 +247,7 @@ jobs: key: avd-${{ env.API_LEVEL }} - name: Create AVD and generate snapshot for caching - if: matrix.e2e-tests == 'flutter-android' && steps.avd-cache.outputs.cache-hit != 'true' + if: matrix.e2e-tests == 'flutter-android' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ env.API_LEVEL }} diff --git a/appium/common/helper.py b/appium/common/helper.py index b78d499f..1565b96e 100644 --- a/appium/common/helper.py +++ b/appium/common/helper.py @@ -35,6 +35,7 @@ def library_version() -> str: return appium_version.version + def encode_file_to_base64(file_path: str) -> str: """Return base64 encoded string for given file""" with open(file_path, 'rb') as file: diff --git a/appium/webdriver/common/appiumby.py b/appium/webdriver/common/appiumby.py index 162159e5..b269bb0f 100644 --- a/appium/webdriver/common/appiumby.py +++ b/appium/webdriver/common/appiumby.py @@ -25,10 +25,10 @@ class AppiumBy(By): ACCESSIBILITY_ID = 'accessibility id' IMAGE = '-image' CUSTOM = '-custom' - + # For Flutter integration usage https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/tree/main FLUTTER_INTEGRATION_SEMANTICS_LABEL = '-flutter semantics label' FLUTTER_INTEGRATION_TYPE = '-flutter type' FLUTTER_INTEGRATION_KEY = '-flutter key' FLUTTER_INTEGRATION_TEXT = '-flutter text' - FLUTTER_INTEGRATION_TEXT_CONTAINING = '-flutter text containing' \ No newline at end of file + FLUTTER_INTEGRATION_TEXT_CONTAINING = '-flutter text containing' diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/flutter_finder.py index 2c120137..5243ee94 100644 --- a/appium/webdriver/flutter_finder.py +++ b/appium/webdriver/flutter_finder.py @@ -16,6 +16,7 @@ # under the License. from typing import Tuple + from appium.webdriver.common.appiumby import AppiumBy diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py index 7137a01a..e6b229c2 100644 --- a/test/functional/flutter_integration/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -16,8 +16,8 @@ from appium.webdriver.flutter_finder import FlutterFinder from test.functional.flutter_integration.helper.test_helper import BaseTestCase +LOGIN_BUTTON_FINDER = FlutterFinder.by_text("Login") -LOGIN_BUTTON_FINDER = FlutterFinder.by_text('Login') class TestFlutterFinders(BaseTestCase): @@ -25,19 +25,19 @@ def test_by_flutter_key(self) -> None: user_name_field_finder = FlutterFinder.by_key('username_text_field') user_name_field = self.driver.find_element(*user_name_field_finder.as_args()) assert user_name_field.text == 'admin' - + user_name_field.clear() user_name_field = self.driver.find_element(*user_name_field_finder.as_args()).send_keys('admin123') assert user_name_field.text == 'admin123' - + def test_by_flutter_type(self) -> None: login_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE,'ElevatedButton') assert login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text == 'Login' - + def test_by_flutter_text(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) assert login_button.text == 'Login' - + login_button.click() slider = self.driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Slider') assert len(slider) == 1 @@ -47,11 +47,11 @@ def test_by_flutter_text_containing(self) -> None: login_button.click() vertical_swipe_label = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical') assert vertical_swipe_label.text == 'Vertical Swiping' - + def test_by_flutter_semantics_label(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Lazy Loading')) element.click() message_field = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field') - assert message_field.text == 'Hello world' \ No newline at end of file + assert message_field.text == 'Hello world' diff --git a/test/functional/flutter_integration/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py index 580a287e..5ae2a706 100644 --- a/test/functional/flutter_integration/helper/desired_capabilities.py +++ b/test/functional/flutter_integration/helper/desired_capabilities.py @@ -19,24 +19,28 @@ def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: desired_caps: Dict[str, Any] = {} if platform_name == 'android': - desired_caps.update({ - 'platformName': 'Android', - 'deviceName': 'Android Emulator', - 'newCommandTimeout': 240, - 'uiautomator2ServerInstallTimeout': 120000, - 'adbExecTimeout': 120000, - 'app': os.getenv('FLUTTER_ANDROID_APP') - }) + desired_caps.update( + { + 'platformName': 'Android', + 'deviceName': 'Android Emulator', + 'newCommandTimeout': 240, + 'uiautomator2ServerInstallTimeout': 120000, + 'adbExecTimeout': 120000, + 'app': os.getenv('FLUTTER_ANDROID_APP'), + } + ) else: - desired_caps.update({ - 'deviceName': os.getenv('IPHONE_MODEL'), - 'platformName': 'iOS', - 'platformVersion': os.getenv('IOS_VERSION'), - 'allowTouchIdEnroll': True, - 'wdaLaunchTimeout': 240000, - 'wdaLocalPort': 8100, - 'eventTimings': True, - 'app': os.getenv('FLUTTER_IOS_APP') - }) + desired_caps.update( + { + 'deviceName': os.getenv('IPHONE_MODEL'), + 'platformName': 'iOS', + 'platformVersion': os.getenv('IOS_VERSION'), + 'allowTouchIdEnroll': True, + 'wdaLaunchTimeout': 240000, + 'wdaLocalPort': 8100, + 'eventTimings': True, + 'app': os.getenv('FLUTTER_IOS_APP'), + } + ) return desired_caps diff --git a/test/unit/helper/test_helper.py b/test/unit/helper/test_helper.py index f1768d48..3061ef1f 100644 --- a/test/unit/helper/test_helper.py +++ b/test/unit/helper/test_helper.py @@ -117,6 +117,7 @@ def ios_w3c_driver() -> 'WebDriver': driver = webdriver.Remote(SERVER_URL_BASE, options=XCUITestOptions().load_capabilities(desired_caps)) return driver + def ios_w3c_driver_with_extensions(extensions) -> 'WebDriver': """Return a W3C driver which is generated by a mock response for iOS @@ -150,6 +151,7 @@ def ios_w3c_driver_with_extensions(extensions) -> 'WebDriver': ) return driver + def flutter_w3c_driver() -> 'WebDriver': """Return a W3C driver which is generated by a mock response for Flutter @@ -198,6 +200,7 @@ def flutter_w3c_driver() -> 'WebDriver': driver = webdriver.Remote(SERVER_URL_BASE, options=UiAutomator2Options().load_capabilities(desired_caps)) return driver + def get_httpretty_request_body(request: 'HTTPrettyRequestEmpty') -> Dict[str, Any]: """Returns utf-8 decoded request body""" return json.loads(request.body.decode('utf-8')) diff --git a/test/unit/webdriver/flutter_integration/flutter_actions_test.py b/test/unit/webdriver/flutter_integration/flutter_actions_test.py index 32ced69f..6a32091d 100644 --- a/test/unit/webdriver/flutter_integration/flutter_actions_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_actions_test.py @@ -13,30 +13,31 @@ # limitations under the License. import os + import httpretty from appium.common.helper import encode_file_to_base64 -from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection -from appium.webdriver.webelement import WebElement as MobileWebElement from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.flutter_finder import FlutterFinder -from test.unit.helper.test_helper import flutter_w3c_driver, appium_command, get_httpretty_request_body +from appium.webdriver.webelement import WebElement as MobileWebElement +from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body class TestFlutterActions(object): - + @httpretty.activate def test_double_click(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + element = MobileWebElement(driver, 'element_id') - flutter.perform_double_click(element, (10,20)) + flutter.perform_double_click(element, (10, 20)) request_body = get_httpretty_request_body(httpretty.last_request()) arguments = request_body['args'][0] @@ -48,12 +49,12 @@ def test_double_click(self): def test_drag_and_drop(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + drag_element = MobileWebElement(driver, 'element_id1') drop_element = MobileWebElement(driver, 'element_id2') flutter.perform_drag_and_drop(drag_element, drop_element) @@ -63,17 +64,17 @@ def test_drag_and_drop(self): assert request_body['script'] == 'flutter: dragAndDrop' assert list(arguments['source'].values())[0] == 'element_id1' assert list(arguments['target'].values())[0] == 'element_id2' - + @httpretty.activate def test_scroll_till_visible(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + finder = FlutterFinder.by_key('message_field') flutter.scroll_till_visible(finder) @@ -81,28 +82,29 @@ def test_scroll_till_visible(self): arguments = request_body['args'][0] expected_arguments = { 'finder': {'using': '-flutter key', 'value': 'message_field'}, - 'scrollDirection': 'down' + 'scrollDirection': 'down', } assert request_body['script'] == 'flutter: scrollTillVisible' assert arguments == expected_arguments - + @httpretty.activate def test_scroll_till_visible_with_kwargs(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + finder = FlutterFinder.by_key('message_field') - scroll_params = {'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), - 'delta': 30, - 'maxScrolls': 30, - 'settleBetweenScrollsTimeout': 5000, - 'dragDuration': 35 - } + scroll_params = { + 'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), + 'delta': 30, + 'maxScrolls': 30, + 'settleBetweenScrollsTimeout': 5000, + 'dragDuration': 35, + } flutter.scroll_till_visible(finder, ScrollDirection.UP, **scroll_params) request_body = get_httpretty_request_body(httpretty.last_request()) @@ -115,7 +117,7 @@ def test_scroll_till_visible_with_kwargs(self): 'dragDuration': 35, 'settleBetweenScrollsTimeout': 5000, 'maxScrolls': 30, - 'delta': 30 + 'delta': 30, } assert arguments == expected_arguments @@ -123,12 +125,12 @@ def test_scroll_till_visible_with_kwargs(self): def test_inject_mock_image_with_file(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') base64_encoded_image = encode_file_to_base64(success_qr_file_path) flutter.inject_mock_image(success_qr_file_path) @@ -142,16 +144,15 @@ def test_inject_mock_image_with_file(self): def test_activate_injected_image(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + flutter.activate_injected_image('213476478') request_body = get_httpretty_request_body(httpretty.last_request()) arguments = request_body['args'][0] assert request_body['script'] == 'flutter: activateInjectedImage' assert arguments == {'imageId': '213476478'} - diff --git a/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py b/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py index 1640e5c7..2e3aa481 100644 --- a/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py @@ -13,25 +13,26 @@ # limitations under the License. import json + import httpretty from appium import webdriver from appium.options.flutter_integration.base import FlutterOptions from test.helpers.constants import SERVER_URL_BASE - - + + class TestFlutterIntegrationDriver: @httpretty.activate def test_create_session(self): - + # Set flutter options flutterOptions = FlutterOptions() flutterOptions.flutter_system_port = 9999 flutterOptions.flutter_enable_mock_camera = True flutterOptions.flutter_element_wait_timeout = 10000 flutterOptions.flutter_server_launch_timeout = 120000 - + httpretty.register_uri( httpretty.POST, f'{SERVER_URL_BASE}/session', @@ -58,4 +59,4 @@ def test_create_session(self): 'appium:flutterServerLaunchTimeout': 120000, } assert request_json.get('desiredCapabilities') is None - assert driver.session_id == 'session-id' \ No newline at end of file + assert driver.session_id == 'session-id' diff --git a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py index 3d481138..11f1233c 100644 --- a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py @@ -15,11 +15,11 @@ import httpretty from appium.webdriver.common.appiumby import AppiumBy -from test.unit.helper.test_helper import flutter_w3c_driver, appium_command, get_httpretty_request_body +from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body class TestFlutterSearchContext(object): - + @httpretty.activate def test_find_element_by_flutter_key(self): driver = flutter_w3c_driver() @@ -28,16 +28,13 @@ def test_find_element_by_flutter_key(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element( - by = AppiumBy.FLUTTER_INTEGRATION_KEY, - value= 'Flutter UI Key', - ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'Flutter UI Key') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter key' assert d['value'] == 'Flutter UI Key' assert el.id == 'element-id' - + @httpretty.activate def test_find_elements_by_flutter_key(self): driver = flutter_w3c_driver() @@ -47,18 +44,14 @@ def test_find_elements_by_flutter_key(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements( - by = AppiumBy.FLUTTER_INTEGRATION_KEY, - value= 'Flutter UI Key', - ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_KEY, 'Flutter UI Key') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter key' assert d['value'] == 'Flutter UI Key' assert els[0].id == 'child-element-id1' assert els[1].id == 'child-element-id2' - - + @httpretty.activate def test_find_element_by_flutter_text(self): driver = flutter_w3c_driver() @@ -67,16 +60,13 @@ def test_find_element_by_flutter_text(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element( - by = AppiumBy.FLUTTER_INTEGRATION_TEXT, - value = 'Flutter UI Text', - ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Flutter UI Text') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter text' assert d['value'] == 'Flutter UI Text' assert el.id == 'element-id' - + @httpretty.activate def test_find_elements_by_flutter_text(self): driver = flutter_w3c_driver() @@ -86,17 +76,14 @@ def test_find_elements_by_flutter_text(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements( - by = AppiumBy.FLUTTER_INTEGRATION_TEXT, - value= 'Flutter UI Text', - ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Flutter UI Text') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter text' assert d['value'] == 'Flutter UI Text' assert els[0].id == 'child-element-id1' assert els[1].id == 'child-element-id2' - + @httpretty.activate def test_find_element_by_flutter_semantics_label(self): driver = flutter_w3c_driver() @@ -105,10 +92,7 @@ def test_find_element_by_flutter_semantics_label(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element( - by = AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, - value = 'Flutter UI Semantics Label', - ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter semantics label' @@ -124,17 +108,14 @@ def test_find_elements_by_flutter_semantics_label(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements( - by = AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, - value= 'Flutter UI Semantics Label', - ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter semantics label' assert d['value'] == 'Flutter UI Semantics Label' assert els[0].id == 'child-element-id1' assert els[1].id == 'child-element-id2' - + @httpretty.activate def test_find_element_by_flutter_type(self): driver = flutter_w3c_driver() @@ -143,16 +124,13 @@ def test_find_element_by_flutter_type(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element( - by = AppiumBy.FLUTTER_INTEGRATION_TYPE, - value = 'Flutter UI Type', - ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Flutter UI Type') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter type' assert d['value'] == 'Flutter UI Type' assert el.id == 'element-id' - + @httpretty.activate def test_find_elements_by_flutter_type(self): driver = flutter_w3c_driver() @@ -162,17 +140,14 @@ def test_find_elements_by_flutter_type(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements( - by = AppiumBy.FLUTTER_INTEGRATION_TYPE, - value= 'Flutter UI Type', - ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Flutter UI Type') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter type' assert d['value'] == 'Flutter UI Type' assert els[0].id == 'child-element-id1' assert els[1].id == 'child-element-id2' - + @httpretty.activate def test_find_element_by_flutter_text_containing(self): driver = flutter_w3c_driver() @@ -181,16 +156,13 @@ def test_find_element_by_flutter_text_containing(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element( - by = AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, - value = 'Flutter UI Partial Text', - ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter text containing' assert d['value'] == 'Flutter UI Partial Text' assert el.id == 'element-id' - + @httpretty.activate def test_find_elements_by_flutter_text_containing(self): driver = flutter_w3c_driver() @@ -200,13 +172,10 @@ def test_find_elements_by_flutter_text_containing(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements( - by = AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, - value= 'Flutter UI Partial Text', - ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text',) d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter text containing' assert d['value'] == 'Flutter UI Partial Text' assert els[0].id == 'child-element-id1' - assert els[1].id == 'child-element-id2' \ No newline at end of file + assert els[1].id == 'child-element-id2' diff --git a/test/unit/webdriver/flutter_integration/flutter_waits_test.py b/test/unit/webdriver/flutter_integration/flutter_waits_test.py index af5329fe..13444134 100644 --- a/test/unit/webdriver/flutter_integration/flutter_waits_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_waits_test.py @@ -14,24 +14,24 @@ import httpretty -from appium.webdriver.webelement import WebElement as MobileWebElement from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand from appium.webdriver.flutter_finder import FlutterFinder -from test.unit.helper.test_helper import flutter_w3c_driver, appium_command, get_httpretty_request_body +from appium.webdriver.webelement import WebElement as MobileWebElement +from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body class TestFlutterWaits(object): - + @httpretty.activate def test_wait_for_visible_with_finder(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + finder = FlutterFinder.by_key('message_field') flutter.wait_for_visible(finder, 5) @@ -40,20 +40,20 @@ def test_wait_for_visible_with_finder(self): assert request_body['script'] == 'flutter: waitForVisible' expected_arguments = { 'locator': {'using': '-flutter key', 'value': 'message_field'}, - 'timeout': 5 + 'timeout': 5, } assert arguments == expected_arguments - + @httpretty.activate def test_wait_for_visible_with_webelement(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + element = MobileWebElement(driver, 'element_id') flutter.wait_for_visible(element, 5) @@ -67,12 +67,12 @@ def test_wait_for_visible_with_webelement(self): def test_wait_for_invisible_with_finder(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + message_field_finder = FlutterFinder.by_key('message_field') flutter.wait_for_invisible(message_field_finder, 5) @@ -81,20 +81,20 @@ def test_wait_for_invisible_with_finder(self): assert request_body['script'] == 'flutter: waitForAbsent' expected_arguments = { 'locator': {'using': '-flutter key', 'value': 'message_field'}, - 'timeout': 5 + 'timeout': 5, } assert arguments == expected_arguments - + @httpretty.activate def test_wait_for_invisible_with_webelement(self): driver = flutter_w3c_driver() flutter = FlutterCommand(driver) - + httpretty.register_uri( httpretty.POST, appium_command('/session/1234567890/execute/sync'), ) - + element = MobileWebElement(driver, 'element_id') flutter.wait_for_invisible(element, 5) @@ -102,4 +102,4 @@ def test_wait_for_invisible_with_webelement(self): arguments = request_body['args'][0] assert request_body['script'] == 'flutter: waitForAbsent' assert list(arguments['element'].values())[0] == 'element_id' - assert arguments['timeout'] == 5 \ No newline at end of file + assert arguments['timeout'] == 5 From 914841c0fd04b3ef86e8fb4bfb3cf2b0966dabaa Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 22:08:22 +0200 Subject: [PATCH 10/21] feat: fix formatting issues --- .../flutter_element_wait_timeout_option.py | 6 +- .../flutter_server_launch_timeout_option.py | 6 +- .../flutter_integration/flutter_commands.py | 41 +++-- .../flutter_integration/commands_test.py | 145 ++++++++++++------ .../flutter_integration/finder_test.py | 21 ++- .../flutter_search_context_test.py | 17 +- 6 files changed, 170 insertions(+), 66 deletions(-) diff --git a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py index ee2932c4..7b960e24 100644 --- a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py +++ b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py @@ -47,5 +47,9 @@ def flutter_element_wait_timeout(self, value: Union[timedelta, int]) -> None: """ self.set_capability( FLUTTER_ELEMENT_WAIT_TIMEOUT, - int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value + ( + int(value.total_seconds() * 1000) + if isinstance(value, timedelta) + else value + ), ) diff --git a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py index c09532e0..7179334e 100644 --- a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py +++ b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py @@ -48,5 +48,9 @@ def flutter_server_launch_timeout(self, value: Union[timedelta, int]) -> None: """ self.set_capability( FLUTTER_SERVER_LAUNCH_TIMEOUT, - int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value + ( + int(value.total_seconds() * 1000) + if isinstance(value, timedelta) + else value + ), ) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index fd2f71aa..91c0fba4 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -16,7 +16,9 @@ from typing import Any, Dict, Optional, Tuple, Union from appium.common.helper import encode_file_to_base64 -from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection +from appium.webdriver.extensions.flutter_integration.scroll_directions import ( + ScrollDirection, +) from appium.webdriver.flutter_finder import FlutterFinder from appium.webdriver.webdriver import WebDriver from appium.webdriver.webelement import WebElement @@ -29,7 +31,11 @@ def __init__(self, driver: WebDriver) -> None: # wait commands - def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: + def wait_for_visible( + self, + locator: Union[WebElement, FlutterFinder], + time_out: Optional[float] = None, + ) -> None: """ Waits for a element to become visible. @@ -46,7 +52,11 @@ def wait_for_visible(self, locator: Union[WebElement, FlutterFinder], time_out: self.execute_flutter_command('waitForVisible', opts) - def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out: Optional[float] = None) -> None: + def wait_for_invisible( + self, + locator: Union[WebElement, FlutterFinder], + time_out: Optional[float] = None, + ) -> None: """ Waits for a element to become invisible. @@ -65,7 +75,9 @@ def wait_for_invisible(self, locator: Union[WebElement, FlutterFinder], time_out # flutter action commands - def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: + def perform_double_click( + self, element: WebElement, offset: Optional[Tuple[int, int]] = None + ) -> None: """ Performs a double-click on the given element, with an optional offset. @@ -81,7 +93,9 @@ def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('doubleClick', opts) - def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: + def perform_long_press( + self, element: WebElement, offset: Optional[Tuple[int, int]] = None + ) -> None: """ Performs a long press on the given element, with an optional offset. @@ -108,9 +122,16 @@ def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: Returns: None: """ - self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) + self.execute_flutter_command( + 'dragAndDrop', {'source': source, 'target': target} + ) - def scroll_till_visible(self, scroll_to: FlutterFinder, scroll_direction: ScrollDirection = ScrollDirection.DOWN, **opts: Any) -> WebElement: + def scroll_till_visible( + self, + scroll_to: FlutterFinder, + scroll_direction: ScrollDirection = ScrollDirection.DOWN, + **opts: Any, + ) -> WebElement: """ Scrolls until the specified element becomes visible. @@ -146,7 +167,9 @@ def inject_mock_image(self, value: str) -> str: base64_encoded_image = encode_file_to_base64(value) else: base64_encoded_image = value - return self.execute_flutter_command('injectImage', {'base64Image': base64_encoded_image}) + return self.execute_flutter_command( + 'injectImage', {'base64Image': base64_encoded_image} + ) def activate_injected_image(self, image_id: str) -> None: """ @@ -174,7 +197,7 @@ def execute_flutter_command(self, scriptName: str, params: dict) -> Any: specific Flutter command being executed. """ return self.driver.execute_script(f'flutter: {scriptName}', params) - + def __get_locator_options(self, locator: Union[WebElement, 'FlutterFinder']) -> Dict[str, dict]: if isinstance(locator, WebElement): return {'element': locator} diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py index 46e01868..c8d936a3 100644 --- a/test/functional/flutter_integration/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -21,13 +21,13 @@ class TestFlutterCommands(BaseTestCase): - + def test_wait_command(self) -> None: self.__open_screen('Lazy Loading') - + message_field_finder = FlutterFinder.by_key('message_field') toggle_button_finder = FlutterFinder.by_key('toggle_button') - + message_field = self.driver.find_element(*message_field_finder.as_args()) toggle_button = self.driver.find_element(*toggle_button_finder.as_args()) assert message_field.is_displayed() == True @@ -40,88 +40,141 @@ def test_wait_command(self) -> None: toggle_button.click() self.flutter_command.wait_for_visible(message_field) assert len(self.driver.find_elements(*message_field_finder.as_args())) == 1 - + def test_scroll_till_visible_command(self) -> None: self.__open_screen('Vertical Swiping') - + java_text_finder = FlutterFinder.by_text('Java') protractor_text_finder = FlutterFinder.by_text('Protractor') - + first_element = self.flutter_command.scroll_till_visible(java_text_finder) assert first_element.get_attribute('displayed') == 'true' - - second_element = self.flutter_command.scroll_till_visible(protractor_text_finder) + + second_element = self.flutter_command.scroll_till_visible( + protractor_text_finder + ) assert second_element.get_attribute('displayed') == 'true' assert first_element.get_attribute('displayed') == 'false' - first_element = self.flutter_command.scroll_till_visible(java_text_finder, ScrollDirection.UP) + first_element = self.flutter_command.scroll_till_visible( + java_text_finder, ScrollDirection.UP + ) assert second_element.get_attribute('displayed') == 'false' assert first_element.get_attribute('displayed') == 'true' - + def test_scroll_till_visible_with_scroll_params_command(self) -> None: self.__open_screen('Vertical Swiping') - - scroll_params = {'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), - 'delta': 30, - 'maxScrolls': 30, - 'settleBetweenScrollsTimeout': 5000, - 'dragDuration': 35 - } - first_element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params) + + scroll_params = { + 'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), + 'delta': 30, + 'maxScrolls': 30, + 'settleBetweenScrollsTimeout': 5000, + 'dragDuration': 35, + } + first_element = self.flutter_command.scroll_till_visible( + FlutterFinder.by_text('Playwright'), + scroll_direction=ScrollDirection.DOWN, + **scroll_params + ) assert first_element.get_attribute('displayed') == 'true' - + def test_double_click_command(self) -> None: self.__open_screen('Double Tap') - double_tap_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'double_tap_button').find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Double Tap') + double_tap_button = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_KEY, 'double_tap_button' + ).find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Double Tap') assert double_tap_button.text == 'Double Tap' self.flutter_command.perform_double_click(double_tap_button) - assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' - + assert ( + self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful' + ).text + == 'Double Tap Successful' + ) + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() - self.flutter_command.perform_double_click(double_tap_button, (10,2)) - assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' - + self.flutter_command.perform_double_click(double_tap_button, (10, 2)) + assert ( + self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful' + ).text + == 'Double Tap Successful' + ) + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() - + def test_long_press_command(self) -> None: self.__open_screen('Long Press') - - long_press_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'long_press_button') + + long_press_button = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_KEY, 'long_press_button' + ) self.flutter_command.perform_long_press(long_press_button) - - success_pop_up = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'It was a long press') + + success_pop_up = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT, 'It was a long press' + ) assert success_pop_up.text == 'It was a long press' assert success_pop_up.is_displayed() == True - + def test_drag_and_drop_command(self) -> None: self.__open_screen('Drag & Drop') - drag_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drag_me') - drop_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drop_zone') + drag_element = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_KEY, 'drag_me' + ) + drop_element = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_KEY, 'drop_zone' + ) self.flutter_command.perform_drag_and_drop(drag_element, drop_element) - assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'The box is dropped').is_displayed() == True - + assert ( + self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT, 'The box is dropped' + ).is_displayed() + == True + ) + def test_camera_mocking(self) -> None: self.__open_screen('Image Picker') - - success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') - second_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'second_qr.png') - + + success_qr_file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png' + ) + second_qr_file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'file', 'second_qr.png' + ) + image_id = self.flutter_command.inject_mock_image(success_qr_file_path) self.flutter_command.inject_mock_image(second_qr_file_path) - self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() + self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image' + ).click() self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() - assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'SecondInjectedImage').is_displayed() == True + assert ( + self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT, 'SecondInjectedImage' + ).is_displayed() + == True + ) self.flutter_command.activate_injected_image(image_id) - self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() + self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image' + ).click() self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() - assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT,'Success!').is_displayed() == True - + assert ( + self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Success!' + ).is_displayed() + == True + ) + def __open_screen(self, screen_name: str) -> None: self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Login').click() - element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text(screen_name)) + element = self.flutter_command.scroll_till_visible( + FlutterFinder.by_text(screen_name) + ) element.click() - diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py index e6b229c2..ee598981 100644 --- a/test/functional/flutter_integration/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -31,8 +31,13 @@ def test_by_flutter_key(self) -> None: assert user_name_field.text == 'admin123' def test_by_flutter_type(self) -> None: - login_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE,'ElevatedButton') - assert login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text == 'Login' + login_button = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TYPE, 'ElevatedButton' + ) + assert ( + login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text + == 'Login' + ) def test_by_flutter_text(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) @@ -45,13 +50,19 @@ def test_by_flutter_text(self) -> None: def test_by_flutter_text_containing(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() - vertical_swipe_label = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical') + vertical_swipe_label = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical' + ) assert vertical_swipe_label.text == 'Vertical Swiping' def test_by_flutter_semantics_label(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() - element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Lazy Loading')) + element = self.flutter_command.scroll_till_visible( + FlutterFinder.by_text('Lazy Loading') + ) element.click() - message_field = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field') + message_field = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field' + ) assert message_field.text == 'Hello world' diff --git a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py index 11f1233c..f9e4351c 100644 --- a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py @@ -92,7 +92,9 @@ def test_find_element_by_flutter_semantics_label(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') + el = driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label' + ) d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter semantics label' @@ -108,7 +110,9 @@ def test_find_elements_by_flutter_semantics_label(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') + els = driver.find_elements( + AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label' + ) d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter semantics label' @@ -156,7 +160,9 @@ def test_find_element_by_flutter_text_containing(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text') + el = driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text' + ) d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter text containing' @@ -172,7 +178,10 @@ def test_find_elements_by_flutter_text_containing(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text',) + els = driver.find_elements( + AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, + 'Flutter UI Partial Text', + ) d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter text containing' From 3868dd1b2b2f8410b743d8824ad27f8f521d50ba Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 22:22:32 +0200 Subject: [PATCH 11/21] feat: fix formatting --- .../flutter_element_wait_timeout_option.py | 6 +- .../flutter_server_launch_timeout_option.py | 6 +- .../flutter_integration/flutter_commands.py | 20 ++--- .../flutter_integration/commands_test.py | 77 ++++--------------- .../flutter_integration/finder_test.py | 21 ++--- .../flutter_search_context_test.py | 12 +-- 6 files changed, 32 insertions(+), 110 deletions(-) diff --git a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py index 7b960e24..6f6b2ae9 100644 --- a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py +++ b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py @@ -47,9 +47,5 @@ def flutter_element_wait_timeout(self, value: Union[timedelta, int]) -> None: """ self.set_capability( FLUTTER_ELEMENT_WAIT_TIMEOUT, - ( - int(value.total_seconds() * 1000) - if isinstance(value, timedelta) - else value - ), + (int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value), ) diff --git a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py index 7179334e..8f8bea4e 100644 --- a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py +++ b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py @@ -48,9 +48,5 @@ def flutter_server_launch_timeout(self, value: Union[timedelta, int]) -> None: """ self.set_capability( FLUTTER_SERVER_LAUNCH_TIMEOUT, - ( - int(value.total_seconds() * 1000) - if isinstance(value, timedelta) - else value - ), + (int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value), ) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 91c0fba4..7b418b20 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -16,9 +16,7 @@ from typing import Any, Dict, Optional, Tuple, Union from appium.common.helper import encode_file_to_base64 -from appium.webdriver.extensions.flutter_integration.scroll_directions import ( - ScrollDirection, -) +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.flutter_finder import FlutterFinder from appium.webdriver.webdriver import WebDriver from appium.webdriver.webelement import WebElement @@ -75,9 +73,7 @@ def wait_for_invisible( # flutter action commands - def perform_double_click( - self, element: WebElement, offset: Optional[Tuple[int, int]] = None - ) -> None: + def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: """ Performs a double-click on the given element, with an optional offset. @@ -93,9 +89,7 @@ def perform_double_click( opts['offset'] = {'x': offset[0], 'y': offset[1]} self.execute_flutter_command('doubleClick', opts) - def perform_long_press( - self, element: WebElement, offset: Optional[Tuple[int, int]] = None - ) -> None: + def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: """ Performs a long press on the given element, with an optional offset. @@ -122,9 +116,7 @@ def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: Returns: None: """ - self.execute_flutter_command( - 'dragAndDrop', {'source': source, 'target': target} - ) + self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) def scroll_till_visible( self, @@ -167,9 +159,7 @@ def inject_mock_image(self, value: str) -> str: base64_encoded_image = encode_file_to_base64(value) else: base64_encoded_image = value - return self.execute_flutter_command( - 'injectImage', {'base64Image': base64_encoded_image} - ) + return self.execute_flutter_command('injectImage', {'base64Image': base64_encoded_image}) def activate_injected_image(self, image_id: str) -> None: """ diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py index c8d936a3..86935626 100644 --- a/test/functional/flutter_integration/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -50,15 +50,11 @@ def test_scroll_till_visible_command(self) -> None: first_element = self.flutter_command.scroll_till_visible(java_text_finder) assert first_element.get_attribute('displayed') == 'true' - second_element = self.flutter_command.scroll_till_visible( - protractor_text_finder - ) + second_element = self.flutter_command.scroll_till_visible(protractor_text_finder) assert second_element.get_attribute('displayed') == 'true' assert first_element.get_attribute('displayed') == 'false' - first_element = self.flutter_command.scroll_till_visible( - java_text_finder, ScrollDirection.UP - ) + first_element = self.flutter_command.scroll_till_visible(java_text_finder, ScrollDirection.UP) assert second_element.get_attribute('displayed') == 'false' assert first_element.get_attribute('displayed') == 'true' @@ -73,9 +69,7 @@ def test_scroll_till_visible_with_scroll_params_command(self) -> None: 'dragDuration': 35, } first_element = self.flutter_command.scroll_till_visible( - FlutterFinder.by_text('Playwright'), - scroll_direction=ScrollDirection.DOWN, - **scroll_params + FlutterFinder.by_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params ) assert first_element.get_attribute('displayed') == 'true' @@ -89,18 +83,14 @@ def test_double_click_command(self) -> None: self.flutter_command.perform_double_click(double_tap_button) assert ( - self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful' - ).text + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' ) self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() self.flutter_command.perform_double_click(double_tap_button, (10, 2)) assert ( - self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful' - ).text + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text == 'Double Tap Successful' ) @@ -109,72 +99,39 @@ def test_double_click_command(self) -> None: def test_long_press_command(self) -> None: self.__open_screen('Long Press') - long_press_button = self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_KEY, 'long_press_button' - ) + long_press_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'long_press_button') self.flutter_command.perform_long_press(long_press_button) - success_pop_up = self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT, 'It was a long press' - ) + success_pop_up = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'It was a long press') assert success_pop_up.text == 'It was a long press' assert success_pop_up.is_displayed() == True def test_drag_and_drop_command(self) -> None: self.__open_screen('Drag & Drop') - drag_element = self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_KEY, 'drag_me' - ) - drop_element = self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_KEY, 'drop_zone' - ) + drag_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drag_me') + drop_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drop_zone') self.flutter_command.perform_drag_and_drop(drag_element, drop_element) - assert ( - self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT, 'The box is dropped' - ).is_displayed() - == True - ) + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'The box is dropped').is_displayed() == True def test_camera_mocking(self) -> None: self.__open_screen('Image Picker') - success_qr_file_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png' - ) - second_qr_file_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'file', 'second_qr.png' - ) + success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') + second_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'second_qr.png') image_id = self.flutter_command.inject_mock_image(success_qr_file_path) self.flutter_command.inject_mock_image(second_qr_file_path) - self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image' - ).click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() - assert ( - self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT, 'SecondInjectedImage' - ).is_displayed() - == True - ) + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'SecondInjectedImage').is_displayed() == True self.flutter_command.activate_injected_image(image_id) - self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image' - ).click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() - assert ( - self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Success!' - ).is_displayed() - == True - ) + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Success!').is_displayed() == True def __open_screen(self, screen_name: str) -> None: self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Login').click() - element = self.flutter_command.scroll_till_visible( - FlutterFinder.by_text(screen_name) - ) + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text(screen_name)) element.click() diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py index ee598981..dd49105b 100644 --- a/test/functional/flutter_integration/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -31,13 +31,8 @@ def test_by_flutter_key(self) -> None: assert user_name_field.text == 'admin123' def test_by_flutter_type(self) -> None: - login_button = self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TYPE, 'ElevatedButton' - ) - assert ( - login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text - == 'Login' - ) + login_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'ElevatedButton') + assert login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text == 'Login' def test_by_flutter_text(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) @@ -50,19 +45,13 @@ def test_by_flutter_text(self) -> None: def test_by_flutter_text_containing(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() - vertical_swipe_label = self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical' - ) + vertical_swipe_label = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical') assert vertical_swipe_label.text == 'Vertical Swiping' def test_by_flutter_semantics_label(self) -> None: login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) login_button.click() - element = self.flutter_command.scroll_till_visible( - FlutterFinder.by_text('Lazy Loading') - ) + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Lazy Loading')) element.click() - message_field = self.driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field' - ) + message_field = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field') assert message_field.text == 'Hello world' diff --git a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py index f9e4351c..e0d20bd0 100644 --- a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py @@ -92,9 +92,7 @@ def test_find_element_by_flutter_semantics_label(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label' - ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter semantics label' @@ -110,9 +108,7 @@ def test_find_elements_by_flutter_semantics_label(self): body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', ) - els = driver.find_elements( - AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label' - ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter semantics label' @@ -160,9 +156,7 @@ def test_find_element_by_flutter_text_containing(self): appium_command('/session/1234567890/element'), body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', ) - el = driver.find_element( - AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text' - ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text') d = get_httpretty_request_body(httpretty.last_request()) assert d['using'] == '-flutter text containing' From 16a1d3d3a7e95b5dba49e5662a459c5c85df4982 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 22:52:22 +0200 Subject: [PATCH 12/21] feat: Debug failing test in CI --- .github/workflows/functional-test.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 47f87de7..64e86185 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -276,7 +276,7 @@ jobs: run: | appium driver install uiautomator2 appium driver install appium-flutter-integration-driver --source npm - nohup appium --allow-insecure=adb_shell --relaxed-security --log-timestamp --log-no-colors 2>&1 > appium_android.log & + nohup appium --allow-insecure=adb_shell --relaxed-security --log-timestamp --log-no-colors 2>&1 > appium_flutter_android.log & - name: Run Android tests if: matrix.e2e-tests == 'flutter-android' @@ -290,12 +290,19 @@ jobs: pipenv lock --clear pipenv install -d --system export PLATFORM=android - pytest test/functional/flutter_integration/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html + pytest test/functional/flutter_integration/*_test.py -k test_camera_mocking --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html target: google_apis profile: Nexus 5X disable-spellchecker: true disable-animations: true + - name: Save server output + if: always() && matrix.e2e-tests == 'android' + uses: actions/upload-artifact@master + with: + name: appium-flutter-android.log + path: appium_flutter_android.log + - name: Select Xcode if: matrix.e2e-tests == 'flutter-ios' uses: maxim-lobanov/setup-xcode@v1 From 0c3829adeb44b354c2e0d9344c39bb6a30d39800 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sat, 21 Sep 2024 23:03:54 +0200 Subject: [PATCH 13/21] feat: save server logs in CI --- .github/workflows/functional-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 64e86185..32731bb2 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -297,7 +297,7 @@ jobs: disable-animations: true - name: Save server output - if: always() && matrix.e2e-tests == 'android' + if: always() && matrix.e2e-tests == 'flutter-android' uses: actions/upload-artifact@master with: name: appium-flutter-android.log From 8cd86557e6760cee2eb690d9dee948373988d7af Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sun, 22 Sep 2024 15:23:38 +0200 Subject: [PATCH 14/21] feat: change andrpid emulator settings --- .github/workflows/functional-test.yml | 48 +++++++++++++-------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 32731bb2..f5f5b153 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -210,7 +210,7 @@ jobs: runs-on: ${{ matrix.platform }} env: - API_LEVEL: 29 + API_LEVEL: 28 ARCH: x86 CI: true XCODE_VERSION: 15.4 @@ -236,27 +236,27 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: AVD cache - if: matrix.e2e-tests == 'flutter-android' - uses: actions/cache@v3 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ env.API_LEVEL }} - - - name: Create AVD and generate snapshot for caching - if: matrix.e2e-tests == 'flutter-android' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ env.API_LEVEL }} - arch: ${{ env.ARCH }} - target: google_apis - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." + # - name: AVD cache + # if: matrix.e2e-tests == 'flutter-android' + # uses: actions/cache@v3 + # id: avd-cache + # with: + # path: | + # ~/.android/avd/* + # ~/.android/adb* + # key: avd-${{ env.API_LEVEL }} + + # - name: Create AVD and generate snapshot for caching + # if: matrix.e2e-tests == 'flutter-android' + # uses: reactivecircus/android-emulator-runner@v2 + # with: + # api-level: ${{ env.API_LEVEL }} + # arch: ${{ env.ARCH }} + # target: google_apis + # force-avd-creation: false + # emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + # disable-animations: false + # script: echo "Generated AVD snapshot for caching." - name: Set up Python 3.12 uses: actions/setup-python@v3 @@ -283,7 +283,6 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ env.API_LEVEL }} - arch: ${{ env.ARCH }} script: | pip install --upgrade pip pip install --upgrade pipenv @@ -291,8 +290,7 @@ jobs: pipenv install -d --system export PLATFORM=android pytest test/functional/flutter_integration/*_test.py -k test_camera_mocking --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html - target: google_apis - profile: Nexus 5X + target: default disable-spellchecker: true disable-animations: true From fe5eb36a4c8cca1056f195b5d07ff25496ed1b40 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sun, 22 Sep 2024 16:26:03 +0200 Subject: [PATCH 15/21] feat: Fix android failing test --- .../flutter_integration/helper/desired_capabilities.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/flutter_integration/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py index 5ae2a706..af884c91 100644 --- a/test/functional/flutter_integration/helper/desired_capabilities.py +++ b/test/functional/flutter_integration/helper/desired_capabilities.py @@ -23,10 +23,11 @@ def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: { 'platformName': 'Android', 'deviceName': 'Android Emulator', - 'newCommandTimeout': 240, + 'newCommandTimeout': 120, 'uiautomator2ServerInstallTimeout': 120000, 'adbExecTimeout': 120000, 'app': os.getenv('FLUTTER_ANDROID_APP'), + 'autoGrantPermissions': True } ) else: From ecc7bc4387faee5163a19f80dca248995fafce92 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sun, 22 Sep 2024 16:47:41 +0200 Subject: [PATCH 16/21] feat: update workflows and fix unity tests --- .github/workflows/functional-test.yml | 25 ++----------------- .../helper/desired_capabilities.py | 2 +- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index f5f5b153..74208d88 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -236,28 +236,6 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - # - name: AVD cache - # if: matrix.e2e-tests == 'flutter-android' - # uses: actions/cache@v3 - # id: avd-cache - # with: - # path: | - # ~/.android/avd/* - # ~/.android/adb* - # key: avd-${{ env.API_LEVEL }} - - # - name: Create AVD and generate snapshot for caching - # if: matrix.e2e-tests == 'flutter-android' - # uses: reactivecircus/android-emulator-runner@v2 - # with: - # api-level: ${{ env.API_LEVEL }} - # arch: ${{ env.ARCH }} - # target: google_apis - # force-avd-creation: false - # emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - # disable-animations: false - # script: echo "Generated AVD snapshot for caching." - - name: Set up Python 3.12 uses: actions/setup-python@v3 with: @@ -289,7 +267,7 @@ jobs: pipenv lock --clear pipenv install -d --system export PLATFORM=android - pytest test/functional/flutter_integration/*_test.py -k test_camera_mocking --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html + pytest test/functional/flutter_integration/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html target: default disable-spellchecker: true disable-animations: true @@ -309,6 +287,7 @@ jobs: - run: defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false - uses: futureware-tech/simulator-action@v3 + if: matrix.e2e-tests == 'flutter-ios' with: # https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md model: ${{ env.IPHONE_MODEL }} diff --git a/test/functional/flutter_integration/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py index af884c91..b0305341 100644 --- a/test/functional/flutter_integration/helper/desired_capabilities.py +++ b/test/functional/flutter_integration/helper/desired_capabilities.py @@ -27,7 +27,7 @@ def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: 'uiautomator2ServerInstallTimeout': 120000, 'adbExecTimeout': 120000, 'app': os.getenv('FLUTTER_ANDROID_APP'), - 'autoGrantPermissions': True + 'autoGrantPermissions': True, } ) else: From c5f02235e0574ad878f027e2a4d16b74bdc777b4 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sun, 22 Sep 2024 17:05:44 +0200 Subject: [PATCH 17/21] feat: remove unnecessary jobs --- .github/workflows/functional-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 74208d88..0dbf2f0c 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -284,7 +284,6 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ env.XCODE_VERSION }} - - run: defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false - uses: futureware-tech/simulator-action@v3 if: matrix.e2e-tests == 'flutter-ios' From 807efcacc829332c1480c7d2fe55f59527bc44fa Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sun, 22 Sep 2024 17:35:04 +0200 Subject: [PATCH 18/21] feat: move flutter_finder under extensions --- .../extensions/flutter_integration/flutter_commands.py | 6 +++--- .../{ => extensions/flutter_integration}/flutter_finder.py | 0 test/functional/flutter_integration/commands_test.py | 2 +- test/functional/flutter_integration/finder_test.py | 2 +- .../webdriver/flutter_integration/flutter_actions_test.py | 2 +- .../webdriver/flutter_integration/flutter_waits_test.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename appium/webdriver/{ => extensions/flutter_integration}/flutter_finder.py (100%) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 7b418b20..691fd333 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -17,7 +17,7 @@ from appium.common.helper import encode_file_to_base64 from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection -from appium.webdriver.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder from appium.webdriver.webdriver import WebDriver from appium.webdriver.webelement import WebElement @@ -135,8 +135,8 @@ def scroll_till_visible( scrollView (str): The view of the scroll. Default value is 'Scrollable' delta (int): delta for the scroll. Default value is 64 maxScrolls (int): Max times to scroll. Default value is 15 - settleBetweenScrollsTimeout (int): settle timeout in milliseconds. Default value is 5000ms - dragDuration (int): time gap between each scroll in milliseconds. Default value is 100ms + settleBetweenScrollsTimeout (float): settle timeout in milliseconds. Default value is 5000 + dragDuration (float): time gap between each scroll in milliseconds. Default value is 100 Returns: Webelement: scrolled element diff --git a/appium/webdriver/flutter_finder.py b/appium/webdriver/extensions/flutter_integration/flutter_finder.py similarity index 100% rename from appium/webdriver/flutter_finder.py rename to appium/webdriver/extensions/flutter_integration/flutter_finder.py diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py index 86935626..5d0610d3 100644 --- a/test/functional/flutter_integration/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -16,7 +16,7 @@ from appium.webdriver.common.appiumby import AppiumBy from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection -from appium.webdriver.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder from test.functional.flutter_integration.helper.test_helper import BaseTestCase diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py index dd49105b..c5faf262 100644 --- a/test/functional/flutter_integration/finder_test.py +++ b/test/functional/flutter_integration/finder_test.py @@ -13,7 +13,7 @@ # limitations under the License. from appium.webdriver.common.appiumby import AppiumBy -from appium.webdriver.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder from test.functional.flutter_integration.helper.test_helper import BaseTestCase LOGIN_BUTTON_FINDER = FlutterFinder.by_text("Login") diff --git a/test/unit/webdriver/flutter_integration/flutter_actions_test.py b/test/unit/webdriver/flutter_integration/flutter_actions_test.py index 6a32091d..55c39fd8 100644 --- a/test/unit/webdriver/flutter_integration/flutter_actions_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_actions_test.py @@ -19,7 +19,7 @@ from appium.common.helper import encode_file_to_base64 from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection -from appium.webdriver.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder from appium.webdriver.webelement import WebElement as MobileWebElement from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body diff --git a/test/unit/webdriver/flutter_integration/flutter_waits_test.py b/test/unit/webdriver/flutter_integration/flutter_waits_test.py index 13444134..338952e2 100644 --- a/test/unit/webdriver/flutter_integration/flutter_waits_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_waits_test.py @@ -15,7 +15,7 @@ import httpretty from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand -from appium.webdriver.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder from appium.webdriver.webelement import WebElement as MobileWebElement from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body From e2e82ac7cb2b58ddb9632303d8cd8a9bf38bb530 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sun, 22 Sep 2024 17:44:09 +0200 Subject: [PATCH 19/21] feat: fix isort issues --- .../extensions/flutter_integration/flutter_commands.py | 2 +- test/functional/flutter_integration/commands_test.py | 2 +- test/unit/webdriver/flutter_integration/flutter_actions_test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 691fd333..d73cff86 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -16,8 +16,8 @@ from typing import Any, Dict, Optional, Tuple, Union from appium.common.helper import encode_file_to_base64 -from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.webdriver import WebDriver from appium.webdriver.webelement import WebElement diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py index 5d0610d3..010c0f8c 100644 --- a/test/functional/flutter_integration/commands_test.py +++ b/test/functional/flutter_integration/commands_test.py @@ -15,8 +15,8 @@ import os from appium.webdriver.common.appiumby import AppiumBy -from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from test.functional.flutter_integration.helper.test_helper import BaseTestCase diff --git a/test/unit/webdriver/flutter_integration/flutter_actions_test.py b/test/unit/webdriver/flutter_integration/flutter_actions_test.py index 55c39fd8..2a21b60c 100644 --- a/test/unit/webdriver/flutter_integration/flutter_actions_test.py +++ b/test/unit/webdriver/flutter_integration/flutter_actions_test.py @@ -18,8 +18,8 @@ from appium.common.helper import encode_file_to_base64 from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand -from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection from appium.webdriver.webelement import WebElement as MobileWebElement from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body From c0f3848ff480380f5d93139eee12511dd0d59de4 Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Sun, 22 Sep 2024 21:37:24 +0200 Subject: [PATCH 20/21] feat: add maintainer details --- .github/workflows/functional-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 0dbf2f0c..5f3f7597 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -198,6 +198,7 @@ jobs: path: appium.log flutter_e2e_test: + # These flutter integration driver tests are maintained by: MummanaSubramanya strategy: fail-fast: false matrix: From e738d3b8798cb8ae1f186822a30ba00dbe81daab Mon Sep 17 00:00:00 2001 From: MummanaSubramanya Date: Mon, 23 Sep 2024 06:54:45 +0200 Subject: [PATCH 21/21] feat: rename timeout variable --- .../flutter_integration/flutter_commands.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index d73cff86..fa9dcaee 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -32,42 +32,42 @@ def __init__(self, driver: WebDriver) -> None: def wait_for_visible( self, locator: Union[WebElement, FlutterFinder], - time_out: Optional[float] = None, + timeout: Optional[float] = None, ) -> None: """ Waits for a element to become visible. Args: locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. - time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + timeout (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. Returns: None: """ opts: Dict[str, Any] = self.__get_locator_options(locator) - if time_out is not None: - opts['timeout'] = time_out + if timeout is not None: + opts['timeout'] = timeout self.execute_flutter_command('waitForVisible', opts) def wait_for_invisible( self, locator: Union[WebElement, FlutterFinder], - time_out: Optional[float] = None, + timeout: Optional[float] = None, ) -> None: """ Waits for a element to become invisible. Args: locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. - time_out (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + timeout (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. Returns: None: """ opts: Dict[str, Any] = self.__get_locator_options(locator) - if time_out is not None: - opts['timeout'] = time_out + if timeout is not None: + opts['timeout'] = timeout self.execute_flutter_command('waitForAbsent', opts)