From 71abfb4de864555361002eae211c704f61245348 Mon Sep 17 00:00:00 2001 From: alpaca00 Date: Sun, 4 May 2025 22:21:36 +0200 Subject: [PATCH 1/3] feat: add method for interacting with the Flutter integration driver --- .../flutter_integration/flutter_commands.py | 29 +++++- .../flutter_render_tree_test.py | 95 +++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/unit/webdriver/flutter_integration/flutter_render_tree_test.py diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 1830d0a2..f1cd32da 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -13,7 +13,7 @@ # limitations under the License. import os -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from appium.common.helper import encode_file_to_base64 from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder @@ -172,6 +172,33 @@ def activate_injected_image(self, image_id: str) -> None: """ self.execute_flutter_command('activateInjectedImage', {'imageId': image_id}) + def get_render_tree( + self, + widget_type: Optional[str] = None, + key: Optional[str] = None, + text: Optional[str] = None, + ) -> List[Optional[Dict]]: + """ + Returns the render tree of the root widget. + + Args: + widget_type (Optional[str]): The type of the widget to primary filter by. + key (Optional[str]): The key of the widget to filter by. + text (Optional[str]): The text of the widget to filter by. + + Returns: + str: The render tree of the current screen. + """ + opts = {} + if widget_type is not None: + opts['widgetType'] = widget_type + if key is not None: + opts['key'] = key + if text is not None: + opts['text'] = text + + return self.execute_flutter_command('renderTree', opts) + 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. diff --git a/test/unit/webdriver/flutter_integration/flutter_render_tree_test.py b/test/unit/webdriver/flutter_integration/flutter_render_tree_test.py new file mode 100644 index 00000000..1d11b5f8 --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_render_tree_test.py @@ -0,0 +1,95 @@ +#!/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.webdriver.extensions.flutter_integration.flutter_commands import ( + FlutterCommand, +) +from test.unit.helper.test_helper import ( + appium_command, + flutter_w3c_driver, + get_httpretty_request_body, +) + + +class TestFlutterRenderTree: + @httpretty.activate + def test_get_render_tree_with_all_filters(self): + expected_body = [{'': 'LoginButton'}] + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + body=json.dumps({'value': expected_body}), + ) + + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + result = flutter.get_render_tree(widget_type='ElevatedButton', key='LoginButton', text='Login') + + request_body = get_httpretty_request_body(httpretty.last_request()) + assert request_body['script'] == 'flutter: renderTree' + assert request_body['args'] == [ + { + 'widgetType': 'ElevatedButton', + 'key': 'LoginButton', + 'text': 'Login', + } + ] + + assert result == expected_body + + @httpretty.activate + def test_get_render_tree_with_partial_filters(self): + expected_body = [{'': 'LoginScreen'}] + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + body=json.dumps({'value': expected_body}), + ) + + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + result = flutter.get_render_tree(widget_type='LoginScreen') + + request_body = get_httpretty_request_body(httpretty.last_request()) + assert request_body['script'] == 'flutter: renderTree' + assert request_body['args'] == [{'widgetType': 'LoginScreen'}] + + assert result == expected_body + + @httpretty.activate + def test_get_render_tree_with_no_filters(self): + expected_body = [{'': 'RootWidget'}] + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + body=json.dumps({'value': expected_body}), + ) + + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + result = flutter.get_render_tree() + + request_body = get_httpretty_request_body(httpretty.last_request()) + assert request_body['script'] == 'flutter: renderTree' + assert request_body['args'] == [{}] + + assert result == expected_body From d2b7b9e4ec47f5c4547b450b00faeeeed92b4d2d Mon Sep 17 00:00:00 2001 From: alpaca00 Date: Sun, 4 May 2025 22:43:03 +0200 Subject: [PATCH 2/3] Fixed the return docstring of the method --- .../extensions/flutter_integration/flutter_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index f1cd32da..4dfe3fa0 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -187,7 +187,7 @@ def get_render_tree( text (Optional[str]): The text of the widget to filter by. Returns: - str: The render tree of the current screen. + List[Optional[Dict]]: A list of dictionaries or None values representing the render tree. """ opts = {} if widget_type is not None: From 633908315f36b655c0c44b83821566a814ef5022 Mon Sep 17 00:00:00 2001 From: alpaca00 Date: Mon, 5 May 2025 15:06:45 +0200 Subject: [PATCH 3/3] Add extended documentation for the method --- .../flutter_integration/flutter_commands.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py index 4dfe3fa0..ea7c2325 100644 --- a/appium/webdriver/extensions/flutter_integration/flutter_commands.py +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -188,6 +188,77 @@ def get_render_tree( Returns: List[Optional[Dict]]: A list of dictionaries or None values representing the render tree. + + The result is a nested list of dictionaries representing each widget and its properties, + such as type, key, size, attribute, state, visual information, and hierarchy. + + The example widget includes the following code, which is rendered as part of the widget tree: + ```dart + Semantics( + key: const Key('add_activity_semantics'), + label: 'add_activity_button', + button: true, + child: FloatingActionButton.small( + key: const Key('add_activity_button'), + tooltip: 'add_activity_button', + heroTag: 'add', + backgroundColor: const Color(0xFF2E2E3A), + onPressed: null, + child: Icon( + Icons.add, + size: 16, + color: Colors.amber.shade200.withOpacity(0.5), + semanticLabel: 'Add icon', + ), + ), + ), + ``` + Example execute command: + >>> flutter_command = FlutterCommand(driver) # noqa + >>> flutter_command.get_render_tree(widget_type='Semantics', key='add_activity_semantics') + output >> [ + { + "type": "Semantics", + "elementType": "SingleChildRenderObjectElement", + "description": "Semantics-[<'add_activity_semantics'>]", + "depth": 0, + "key": "[<'add_activity_semantics'>]", + "attributes": { + "semanticsLabel": "add_activity_button" + }, + "visual": {}, + "state": {}, + "rect": { + "x": 0, + "y": 0, + "width": 48, + "height": 48 + }, + "children": [ + { + "type": "FloatingActionButton", + "elementType": "StatelessElement", + "description": "FloatingActionButton-[<'add_activity_button'>]", + "depth": 1, + "key": "[<'add_activity_button'>]", + "attributes": {}, + "visual": {}, + "state": {}, + "rect": { + "x": 0, + "y": 0, + "width": 48, + "height": 48 + }, + "children": [ + {...}, + "children": [...] + } + ] + } + ] + } + ] """ opts = {} if widget_type is not None: