Skip to content

Commit fbcd000

Browse files
authored
feat: add command to fetch render tree by widget type (#29)
* feat: add command to fetch render tree by widget type, (update dependencies for APK build) * Changed widgetType to an optional argument and extended widgets properties in the response * Refactored according to the review comment
1 parent 0019f6e commit fbcd000

8 files changed

Lines changed: 264 additions & 6 deletions

File tree

demo-app/android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
buildscript {
2-
ext.kotlin_version = '1.7.10'
2+
ext.kotlin_version = '1.8.22'
33
repositories {
44
google()
55
mavenCentral()

demo-app/android/gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
33
zipStoreBase=GRADLE_USER_HOME
44
zipStorePath=wrapper/dists
5-
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
5+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip

demo-app/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ environment:
2020
# versions available, run `flutter pub outdated`.
2121
dependencies:
2222
webview_flutter: ^4.5.0
23-
carousel_slider: ^4.2.1
23+
carousel_slider: ^5.0.0
2424
permission_handler: ^11.3.1
2525
url_launcher: ^6.3.0
2626
qr_code_dart_scan: ^0.8.1
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import 'dart:io';
2+
3+
import 'package:appium_flutter_server/src/handler/request/request_handler.dart';
4+
import 'package:appium_flutter_server/src/models/api/appium_response.dart';
5+
import 'package:appium_flutter_server/src/utils/element_helper.dart';
6+
7+
import 'package:shelf_plus/shelf_plus.dart';
8+
9+
class RenderTreeHandler extends RequestHandler {
10+
RenderTreeHandler(super.route);
11+
12+
@override
13+
Future<AppiumResponse> handle(Request request) async {
14+
final Map<String, dynamic>? bodyParams = await request.body.asJson;
15+
16+
final String? widgetType = bodyParams?['widgetType'] as String?;
17+
final String? text = bodyParams?['text'] as String?;
18+
final String? key = bodyParams?['key'] as String?;
19+
20+
try {
21+
final widgetTree = await ElementHelper.getRenderTreeByType(
22+
widgetType: widgetType,
23+
text: text,
24+
key: key,
25+
);
26+
27+
return AppiumResponse(getSessionId(request), widgetTree);
28+
} catch (e) {
29+
return AppiumResponse.withError(
30+
getSessionId(request),
31+
'Error: ${e.toString()}',
32+
null,
33+
HttpStatus.internalServerError,
34+
);
35+
}
36+
}
37+
}

server/lib/src/runner.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import 'package:package_info_plus/package_info_plus.dart';
1010
const MAX_TEST_DURATION_SECS = 24 * 60 * 60;
1111
// Need a better way to fetch this for automated release, this needs to be updated along with version bump
1212
// Can stay for now as it is not a breaking change
13-
const serverVersion = '0.0.27';
13+
const serverVersion = '0.0.28';
1414

1515
void initializeTest({Widget? app, Function? callback}) async {
1616
IntegrationTestWidgetsFlutterBinding binding =

server/lib/src/server.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'package:appium_flutter_server/src/handler/gesture/scroll_till_visible.da
1212
import 'package:appium_flutter_server/src/handler/get_attribute.dart';
1313
import 'package:appium_flutter_server/src/handler/get_name.dart';
1414
import 'package:appium_flutter_server/src/handler/get_rect.dart';
15+
import 'package:appium_flutter_server/src/handler/render_tree.dart';
1516
import 'package:appium_flutter_server/src/handler/get_size.dart';
1617
import 'package:appium_flutter_server/src/handler/get_text.dart';
1718
import 'package:appium_flutter_server/src/handler/new_session.dart';
@@ -72,13 +73,15 @@ class FlutterServer {
7273
_registerPost(NewSessionHandler("/session"));
7374
_registerPost(FindElementHandler("/session/<sessionId>/element"));
7475
_registerPost(FindElementstHandler("/session/<sessionId>/elements"));
76+
_registerPost(
77+
RenderTreeHandler("/session/<sessionId>/element/render_tree"));
7578
_registerPost(ClickHandler("/session/<sessionId>/element/<id>/click"));
7679
_registerPost(
7780
DoubleClickHandler("/session/<sessionId>/element/<id>/double_click"));
7881
_registerPost(PressBackHandler("/session/<sessionId>/back"));
7982
_registerPost(InjectImage("/session/<sessionId>/inject_image"));
80-
_registerPost(ActivateInjectImage("/session/<sessionId>/activate_inject_image"));
81-
83+
_registerPost(
84+
ActivateInjectImage("/session/<sessionId>/activate_inject_image"));
8285
/* Gesture handler */
8386
_registerPost(
8487
LongPressHandler("/session/<sessionId>/appium/gestures/long_press"));

server/lib/src/utils/element_helper.dart

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:appium_flutter_server/src/models/api/gesture.dart';
1111
import 'package:appium_flutter_server/src/models/api/find_element.dart';
1212
import 'package:appium_flutter_server/src/models/session.dart';
1313
import 'package:appium_flutter_server/src/utils/flutter_settings.dart';
14+
import 'package:appium_flutter_server/src/utils/ui_serialization/element_serializer.dart';
1415
import 'package:flutter/gestures.dart';
1516
import 'package:flutter/material.dart';
1617
import 'package:flutter/rendering.dart';
@@ -569,4 +570,67 @@ class ElementHelper {
569570
}
570571
}
571572
}
573+
574+
static Future<Map<String, dynamic>> _serializeElement(
575+
Element element, {
576+
Set<Element>? visited,
577+
int depth = 0,
578+
}) =>
579+
ElementSerializer.serialize(element, visited: visited, depth: depth);
580+
581+
static Future<List<Map<String, dynamic>>> getRenderTreeByType({
582+
String? widgetType,
583+
String? text,
584+
String? key,
585+
}) async {
586+
final tester = _getTester();
587+
final rootElement = tester.binding.rootElement;
588+
589+
if ((widgetType == null || widgetType.isEmpty) && rootElement != null) {
590+
return [await _serializeElement(rootElement)];
591+
}
592+
if (rootElement == null) {
593+
return [];
594+
}
595+
596+
final matchedElements = <Element>[];
597+
598+
Future<void> search(Element element) async {
599+
final widget = element.widget;
600+
final typeMatches = widget.runtimeType.toString() == widgetType;
601+
final keyMatches =
602+
key == null || widget.key?.toString().contains(key) == true;
603+
bool textMatches = text == null;
604+
if (text != null &&
605+
(widget is Text ||
606+
widget is RichText ||
607+
widget is EditableText ||
608+
widget is TextField)) {
609+
try {
610+
final flutterElement = FlutterElement.fromBy(find.byWidget(widget));
611+
final elementText = await ElementHelper.getText(flutterElement);
612+
textMatches = elementText == text;
613+
} catch (_) {
614+
textMatches = false;
615+
}
616+
}
617+
618+
if (typeMatches && keyMatches && textMatches) {
619+
matchedElements.add(element);
620+
}
621+
622+
element.visitChildren(search);
623+
}
624+
625+
await search(rootElement);
626+
if (matchedElements.isEmpty) {
627+
return [];
628+
}
629+
final results = <Map<String, dynamic>>[];
630+
631+
for (final element in matchedElements) {
632+
results.add(await _serializeElement(element));
633+
}
634+
return results;
635+
}
572636
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import 'package:appium_flutter_server/src/internal/flutter_element.dart';
2+
import 'package:appium_flutter_server/src/utils/element_helper.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
6+
class ElementSerializer {
7+
static Future<Map<String, dynamic>> serialize(
8+
Element element, {
9+
Set<Element>? visited,
10+
int depth = 0,
11+
}) async {
12+
visited ??= <Element>{};
13+
if (visited.contains(element)) {
14+
return {};
15+
}
16+
visited.add(element);
17+
18+
final widget = element.widget;
19+
final renderObject = element.renderObject;
20+
final rect = renderObject?.paintBounds;
21+
22+
final children =
23+
await _serializeChildren(element, visited: visited, depth: depth);
24+
25+
final attributes = await _serializeAttributes(widget);
26+
final visualProperties = await _serializeVisualProperties(widget);
27+
final stateProperties = _serializeStateProperties(widget);
28+
29+
return {
30+
'type': widget.runtimeType.toString(),
31+
'elementType': element.runtimeType.toString(),
32+
'description': widget.toStringShort(),
33+
'depth': depth,
34+
if (widget.key != null) 'key': widget.key.toString(),
35+
'attributes': attributes,
36+
'visual': visualProperties,
37+
'state': stateProperties,
38+
if (rect != null)
39+
'rect': {
40+
'x': rect.left,
41+
'y': rect.top,
42+
'width': rect.width,
43+
'height': rect.height,
44+
},
45+
'children': children,
46+
};
47+
}
48+
49+
static Future<List<Map<String, dynamic>>> _serializeChildren(
50+
Element element, {
51+
required Set<Element> visited,
52+
required int depth,
53+
}) async {
54+
final List<Map<String, dynamic>> children = [];
55+
final List<Future<Map<String, dynamic>>> childrenFutures = [];
56+
element.visitChildren((child) {
57+
childrenFutures.add(serialize(child, visited: visited, depth: depth + 1));
58+
});
59+
final resolvedChildren = await Future.wait(childrenFutures);
60+
for (final childJson in resolvedChildren) {
61+
if (childJson.isNotEmpty) {
62+
children.add(childJson);
63+
}
64+
}
65+
return children;
66+
}
67+
68+
static Future<Map<String, String?>> _serializeAttributes(
69+
Widget widget) async {
70+
String? text;
71+
try {
72+
final flutterElement = FlutterElement.fromBy(find.byWidget(widget));
73+
text = await ElementHelper.getText(flutterElement);
74+
} catch (_) {
75+
text = null;
76+
}
77+
78+
String? semanticsLabel;
79+
String? tooltip;
80+
String? hintText;
81+
82+
if (widget is Semantics) {
83+
semanticsLabel = widget.properties.label;
84+
} else if (widget is Tooltip) {
85+
tooltip = widget.message;
86+
} else if (widget is TextField) {
87+
hintText = widget.decoration?.hintText;
88+
}
89+
90+
return {
91+
if (text?.isNotEmpty ?? false) 'text': text,
92+
if (semanticsLabel != null) 'semanticsLabel': semanticsLabel,
93+
if (tooltip != null) 'tooltip': tooltip,
94+
if (hintText != null) 'hintText': hintText,
95+
};
96+
}
97+
98+
static Future<Map<String, dynamic>> _serializeVisualProperties(
99+
Widget widget) async {
100+
double? fontSize;
101+
String? fontWeight;
102+
String? fontStyle;
103+
String? backgroundColor;
104+
String? color;
105+
106+
if (widget is Text) {
107+
final style = widget.style;
108+
fontSize = style?.fontSize;
109+
fontWeight = style?.fontWeight?.toString();
110+
fontStyle = style?.fontStyle?.toString();
111+
color = style?.color?.toString();
112+
} else if (widget is Container) {
113+
if (widget.decoration is BoxDecoration) {
114+
backgroundColor =
115+
(widget.decoration as BoxDecoration).color?.toString();
116+
}
117+
} else if (widget is ElevatedButton) {
118+
final backgroundColorProperty = widget.style?.backgroundColor;
119+
final resolvedColor = backgroundColorProperty?.resolve(<WidgetState>{});
120+
if (resolvedColor != null) {
121+
backgroundColor = resolvedColor.toString();
122+
}
123+
}
124+
125+
return {
126+
if (fontSize != null) 'fontSize': fontSize,
127+
if (fontWeight != null) 'fontWeight': fontWeight,
128+
if (fontStyle != null) 'fontStyle': fontStyle,
129+
if (backgroundColor != null) 'backgroundColor': backgroundColor,
130+
if (color != null) 'color': color,
131+
};
132+
}
133+
134+
static Map<String, dynamic> _serializeStateProperties(Widget widget) {
135+
bool? enabled;
136+
bool? focused;
137+
bool? visible;
138+
139+
if (widget is EditableText) {
140+
focused = widget.focusNode.hasFocus;
141+
} else if (widget is TextField) {
142+
enabled = widget.enabled ?? true;
143+
focused = widget.focusNode?.hasFocus ?? false;
144+
} else if (widget is Visibility) {
145+
visible = widget.visible;
146+
}
147+
148+
return {
149+
if (enabled != null) 'enabled': enabled,
150+
if (focused != null) 'focused': focused,
151+
if (visible != null) 'visible': visible,
152+
};
153+
}
154+
}

0 commit comments

Comments
 (0)