diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2fb4e2b7..ac74e59e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ ); ``` +- Collect touch breadcrumbs for all buttons, not just those with `key` specified. ([#2242](https://github.com/getsentry/sentry-dart/pull/2242)) + ### Dependencies - Bump Cocoa SDK from v8.35.1 to v8.36.0 ([#2252](https://github.com/getsentry/sentry-dart/pull/2252)) diff --git a/dart/lib/src/protocol/breadcrumb.dart b/dart/lib/src/protocol/breadcrumb.dart index 9ece68d032..282a5cee8f 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -105,42 +105,17 @@ class Breadcrumb { String? viewId, String? viewClass, }) { - final newData = data ?? {}; - var path = ''; - - if (viewId != null) { - newData['view.id'] = viewId; - path = viewId; - } - - if (newData.containsKey('label')) { - if (path.isEmpty) { - path = newData['label']; - } else { - path = "$path, label: ${newData['label']}"; - } - } - - if (viewClass != null) { - newData['view.class'] = viewClass; - if (path.isEmpty) { - path = viewClass; - } else { - path = "$viewClass($path)"; - } - } - - if (path.isNotEmpty && !newData.containsKey('path')) { - newData['path'] = path; - } - return Breadcrumb( message: message, level: level, category: 'ui.$subCategory', type: 'user', timestamp: timestamp, - data: newData, + data: { + if (viewId != null) 'view.id': viewId, + if (viewClass != null) 'view.class': viewClass, + if (data != null) ...data, + }, ); } diff --git a/dart/test/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index 2dea300561..24f0a1b408 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -222,7 +222,6 @@ void main() { 'foo': 'bar', 'view.id': 'foo', 'view.class': 'bar', - 'path': 'bar(foo)', }, }); }); diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt index 3dd549802f..a711a36439 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt @@ -8,6 +8,8 @@ import io.sentry.rrweb.RRWebSpanEvent import java.util.Date private const val MILLIS_PER_SECOND = 1000.0 +private const val MAX_PATH_ITEMS = 4 +private const val MAX_PATH_IDENTIFIER_LENGTH = 20 class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter() { internal companion object { @@ -30,7 +32,7 @@ class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter( "ui.click" -> newRRWebBreadcrumb(breadcrumb).apply { category = "ui.tap" - message = breadcrumb.data["path"] as String? + message = getTouchPathMessage(breadcrumb.data["path"]) } else -> { @@ -83,4 +85,34 @@ class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter( } return rrWebEvent } + + private fun getTouchPathMessage(maybePath: Any?): String? { + if (maybePath !is List<*> || maybePath.isEmpty()) { + return null + } + + val message = StringBuilder() + for (i in Math.min(MAX_PATH_ITEMS, maybePath.size) - 1 downTo 0) { + val item = maybePath[i] + if (item !is Map<*, *>) { + continue + } + + message.append(item["element"] ?: "?") + + var identifier = item["label"] ?: item["name"] + if (identifier is String && identifier.isNotEmpty()) { + if (identifier.length > MAX_PATH_IDENTIFIER_LENGTH) { + identifier = identifier.substring(0, MAX_PATH_IDENTIFIER_LENGTH - "...".length) + "..." + } + message.append("(").append(identifier).append(")") + } + + if (i > 0) { + message.append(" > ") + } + } + + return message.toString() + } } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt index 41209f75b6..ba285a12a0 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -12,7 +12,7 @@ internal class SentryFlutterReplayRecorder( private val channel: MethodChannel, private val integration: ReplayIntegration, ) : Recorder { - override fun start(config: ScreenshotRecorderConfig) { + override fun start(recorderConfig: ScreenshotRecorderConfig) { val cacheDirPath = integration.replayCacheDir?.absolutePath if (cacheDirPath == null) { Log.w("Sentry", "Replay cache directory is null, can't start replay recorder.") @@ -24,9 +24,9 @@ internal class SentryFlutterReplayRecorder( "ReplayRecorder.start", mapOf( "directory" to cacheDirPath, - "width" to config.recordingWidth, - "height" to config.recordingHeight, - "frameRate" to config.frameRate, + "width" to recorderConfig.recordingWidth, + "height" to recorderConfig.recordingHeight, + "frameRate" to recorderConfig.frameRate, "replayId" to integration.getReplayId().toString(), ), ) diff --git a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m index 75b073de82..bde889b6bf 100644 --- a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m +++ b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m @@ -38,7 +38,7 @@ - (instancetype _Nonnull)init { if ([breadcrumb.category isEqualToString:@"ui.click"]) { return [self convertFrom:breadcrumb withCategory:@"ui.tap" - andMessage:breadcrumb.data[@"path"]]; + andMessage:[self getTouchPathMessage:breadcrumb.data[@"path"]]]; } SentryRRWebEvent *nativeBreadcrumb = @@ -112,6 +112,42 @@ - (NSDate *_Nonnull)dateFrom:(NSNumber *_Nonnull)timestamp { return [NSDate dateWithTimeIntervalSince1970:(timestamp.doubleValue / 1000)]; } +- (NSString * _Nullable)getTouchPathMessage:(id _Nullable)maybePath { + if (![maybePath isKindOfClass:[NSArray class]]) { + return nil; + } + + NSArray *path = (NSArray *)maybePath; + if (path.count == 0) { + return nil; + } + + NSMutableString *message = [NSMutableString string]; + for (NSInteger i = MIN(3, path.count - 1); i >= 0; i--) { + id item = path[i]; + if (![item isKindOfClass:[NSDictionary class]]) { + continue; + } + + NSDictionary *itemDict = (NSDictionary *)item; + [message appendString:itemDict[@"element"] ?: @"?"]; + + id identifier = itemDict[@"label"] ?: itemDict[@"name"]; + if ([identifier isKindOfClass:[NSString class]] && [(NSString *)identifier length] > 0) { + NSString *identifierStr = (NSString *)identifier; + if (identifierStr.length > 20) { + identifierStr = [[identifierStr substringToIndex:17] stringByAppendingString:@"..."]; + } + [message appendFormat:@"(%@)", identifierStr]; + } + + if (i > 0) { + [message appendString:@" > "]; + } + } + + return message.length > 0 ? message : nil; +} @end #endif diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 308ed805b0..18b9d8c919 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -202,14 +202,14 @@ class SentryFlutterOptions extends SentryOptions { /// /// Requires adding the [SentryUserInteractionWidget] to the widget tree. /// Example: - /// runApp(SentryUserInteractionWidget(child: App())); + /// runApp(SentryWidget(child: App())); bool enableUserInteractionBreadcrumbs = true; /// Enables the Auto instrumentation for user interaction tracing. /// /// Requires adding the [SentryUserInteractionWidget] to the widget tree. /// Example: - /// runApp(SentryUserInteractionWidget(child: App())); + /// runApp(SentryWidget(child: App())); bool enableUserInteractionTracing = true; /// Enable or disable the tracing of time to full display (TTFD). diff --git a/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart b/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart index 018f750a2e..45c3a0921c 100644 --- a/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart +++ b/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart @@ -208,7 +208,7 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import '../widget_utils.dart'; -import 'user_interaction_widget.dart'; +import 'user_interaction_info.dart'; const _tapDeltaArea = 20 * 20; Element? _clickTrackerElement; @@ -223,7 +223,7 @@ Element? _clickTrackerElement; /// Mostly for onPressed, onTap, and onLongPress events /// /// Example on how to set up: -/// runApp(SentryUserInteractionWidget(child: App())); +/// runApp(SentryWidget(child: App())); /// /// For transactions, enable it in the [SentryFlutterOptions.enableUserInteractionTracing]. /// The idle timeout can be configured in the [SentryOptions.idleTimeout]. @@ -272,7 +272,7 @@ class _SentryUserInteractionWidgetState extends State { int? _lastPointerId; Offset? _lastPointerDownLocation; - UserInteractionWidget? _lastTappedWidget; + UserInteractionInfo? _lastTappedWidget; ISentrySpan? _activeTransaction; Hub get _hub => widget._hub; @@ -294,68 +294,129 @@ class _SentryUserInteractionWidgetState } void _onPointerDown(PointerDownEvent event) { - _lastPointerId = event.pointer; - _lastPointerDownLocation = event.localPosition; + try { + _lastPointerId = event.pointer; + _lastPointerDownLocation = event.localPosition; + } catch (exception, stacktrace) { + _options?.logger( + SentryLevel.error, + 'Error while handling pointer-down event $event in $SentryUserInteractionWidget', + exception: exception, + stackTrace: stacktrace, + ); + // ignore: invalid_use_of_internal_member + if (_options?.automatedTestMode ?? false) { + rethrow; + } + } } void _onPointerUp(PointerUpEvent event) { - // Figure out if something was tapped - final location = _lastPointerDownLocation; - if (location == null || event.pointer != _lastPointerId) { - return; - } - final delta = Offset( - location.dx - event.localPosition.dx, - location.dy - event.localPosition.dy, - ); + try { + // Figure out if something was tapped + final location = _lastPointerDownLocation; + if (location == null || event.pointer != _lastPointerId) { + return; + } + final delta = Offset( + location.dx - event.localPosition.dx, + location.dy - event.localPosition.dy, + ); - if (delta.distanceSquared < _tapDeltaArea) { - // Widget was tapped - _onTappedAt(event.localPosition); + if (delta.distanceSquared < _tapDeltaArea) { + // Widget was tapped + _onTappedAt(event.localPosition); + } + } catch (exception, stacktrace) { + _options?.logger( + SentryLevel.error, + 'Error while handling pointer-up event $event in $SentryUserInteractionWidget', + exception: exception, + stackTrace: stacktrace, + ); + // ignore: invalid_use_of_internal_member + if (_options?.automatedTestMode ?? false) { + rethrow; + } } } void _onTappedAt(Offset position) { - final tappedWidget = _getElementAt(position); - final keyValue = - WidgetUtils.toStringValue(tappedWidget?.element.widget.key); - if (tappedWidget == null || keyValue == null) { + final tapInfo = _getElementAt(position); + if (tapInfo == null) { return; } - final element = tappedWidget.element; - - Map? data; - // ignore: invalid_use_of_internal_member - if ((_options?.sendDefaultPii ?? false) && - tappedWidget.description.isNotEmpty) { - data = {}; - data['label'] = tappedWidget.description; + + final widgetKey = WidgetUtils.toStringValue(tapInfo.element.widget.key); + _createBreadcrumbOnTap(tapInfo, widgetKey); + _startTransactionOnTap(tapInfo, widgetKey); + } + + void _createBreadcrumbOnTap(UserInteractionInfo info, String? widgetKey) { + if (!(_options?.enableUserInteractionBreadcrumbs ?? false)) { + return; } - const category = 'click'; - // ignore: invalid_use_of_internal_member - if (_options?.enableUserInteractionBreadcrumbs ?? false) { - final crumb = Breadcrumb.userInteraction( - subCategory: category, - viewId: keyValue, - viewClass: tappedWidget.type, // to avoid minification - data: data, - ); - final hint = Hint.withMap({TypeCheckHint.widget: element.widget}); - _hub.addBreadcrumb(crumb, hint: hint); + final label = _getLabelRecursively(info.element); + final data = { + 'path': _getTouchPath(info.element), + if (label != null) 'label': label + }; + + final crumb = Breadcrumb.userInteraction( + subCategory: 'click', + viewId: widgetKey, + viewClass: info.type, // to avoid minification + data: data, + ); + final hint = Hint.withMap({TypeCheckHint.widget: info.element.widget}); + _hub.addBreadcrumb(crumb, hint: hint); + } + + List> _getTouchPath(Element element) { + final path = >[]; + + bool addToPath(Element element) { + // Break at the boundary (i.e. this [SentryUserInteractionWidget]). + if (element.widget == widget) { + return false; + } + + final widgetName = element.widget.runtimeType.toString(); + if (!widgetName.startsWith('_')) { + final info = { + 'name': WidgetUtils.toStringValue(element.widget.key), + 'element': _getElementType(element) ?? widgetName, + 'label': _getLabel(element, true), + }..removeWhere((key, value) => value == null); + if (info.isNotEmpty) { + path.add(info); + } + } + + return path.length < 10; + } + + if (addToPath(element)) { + element.visitAncestorElements(addToPath); } - // ignore: invalid_use_of_internal_member - if (!(_options?.isTracingEnabled() ?? false) || + return path; + } + + void _startTransactionOnTap(UserInteractionInfo info, String? widgetKey) { + if (widgetKey == null || + !(_options?.isTracingEnabled() ?? false) || !(_options?.enableUserInteractionTracing ?? false)) { return; } + final element = info.element; // getting the name of the screen using ModalRoute.of(context).settings.name // is expensive, so we expect that the keys are unique across the app final transactionContext = SentryTransactionContext( - keyValue, - 'ui.action.$category', + widgetKey, + 'ui.action.click', transactionNameSource: SentryTransactionNameSource.component, ); @@ -365,7 +426,6 @@ class _SentryUserInteractionWidgetState if (_isElementMounted(lastElement) && _isElementMounted(element) && lastElement?.widget == element.widget && - _lastTappedWidget?.eventType == tappedWidget.eventType && !activeTransaction.finished) { // ignore: invalid_use_of_internal_member activeTransaction.scheduleFinish(); @@ -382,7 +442,7 @@ class _SentryUserInteractionWidgetState } } - _lastTappedWidget = tappedWidget; + _lastTappedWidget = info; bool hasRunningTransaction = false; _hub.configureScope((scope) { @@ -399,9 +459,7 @@ class _SentryUserInteractionWidgetState _activeTransaction = _hub.startTransactionWithContext( transactionContext, waitForChildren: true, - autoFinishAfter: - // ignore: invalid_use_of_internal_member - _options?.idleTimeout, + autoFinishAfter: _options?.idleTimeout, trimEnd: true, ); @@ -415,43 +473,53 @@ class _SentryUserInteractionWidgetState }); } - String _findDescriptionOf(Element element, bool allowText) { - var description = ''; - - // traverse tree to find a suiting element - void descriptionFinder(Element element) { - bool foundDescription = false; + String? _getLabel(Element element, bool allowText) { + String? label; + if (_options?.sendDefaultPii ?? false) { final widget = element.widget; if (allowText && widget is Text) { - final data = widget.data; - if (data != null && data.isNotEmpty) { - description = data; - foundDescription = true; - } + label = widget.data; } else if (widget is Semantics) { - if (widget.properties.label?.isNotEmpty ?? false) { - description = widget.properties.label!; - foundDescription = true; - } + label = widget.properties.label; } else if (widget is Icon) { - if (widget.semanticLabel?.isNotEmpty ?? false) { - description = widget.semanticLabel!; - foundDescription = true; - } + label = widget.semanticLabel; + } else if (widget is Tooltip) { + label = widget.message; } - if (!foundDescription) { - element.visitChildren(descriptionFinder); + if (label?.isEmpty ?? true) { + label = null; } } - element.visitChildren(descriptionFinder); + return label; + } + + String? _getLabelRecursively(Element element) { + String? label; + + if (_options?.sendDefaultPii ?? false) { + final widget = element.widget; + final allowText = widget is ButtonStyleButton || + widget is MaterialButton || + widget is CupertinoButton; + + // traverse tree to find a suiting element + void descriptionFinder(Element element) { + label ??= _getLabel(element, allowText); + if (label == null) { + element.visitChildren(descriptionFinder); + } + } + + descriptionFinder(element); + } - return description; + return label; } - UserInteractionWidget? _getElementAt(Offset position) { + UserInteractionInfo? _getElementAt(Offset position) { // WidgetsBinding.instance.renderViewElement does not work, so using // the element from createElement final rootElement = _clickTrackerElement; @@ -459,7 +527,7 @@ class _SentryUserInteractionWidgetState return null; } - UserInteractionWidget? tappedWidget; + UserInteractionInfo? tappedWidget; void elementFinder(Element element) { if (tappedWidget != null) { @@ -487,7 +555,13 @@ class _SentryUserInteractionWidgetState return; } - tappedWidget = _getDescriptionFrom(element); + final type = _getElementType(element); + if (type != null) { + tappedWidget = UserInteractionInfo( + element: element, + type: type, + ); + } if (tappedWidget == null || !hitFound) { element.visitChildElements(elementFinder); @@ -499,71 +573,36 @@ class _SentryUserInteractionWidgetState return tappedWidget; } - UserInteractionWidget? _getDescriptionFrom(Element element) { + String? _getElementType(Element element) { final widget = element.widget; // Used by ElevatedButton, TextButton, OutlinedButton. if (widget is ButtonStyleButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, true), - type: 'ButtonStyleButton', - eventType: 'onClick', - ); + return 'ButtonStyleButton'; } } else if (widget is MaterialButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, true), - type: 'MaterialButton', - eventType: 'onClick', - ); + return 'MaterialButton'; } } else if (widget is CupertinoButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, true), - type: 'CupertinoButton', - eventType: 'onPressed', - ); + return 'CupertinoButton'; } } else if (widget is InkWell) { if (widget.onTap != null) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'InkWell', - eventType: 'onTap', - ); + return 'InkWell'; } } else if (widget is IconButton) { if (widget.onPressed != null) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'IconButton', - eventType: 'onPressed', - ); + return 'IconButton'; } } else if (widget is PopupMenuButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'PopupMenuButton', - eventType: 'onTap', - ); + return 'PopupMenuButton'; } } else if (widget is PopupMenuItem) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'PopupMenuItem', - eventType: 'onTap', - ); + return 'PopupMenuItem'; } } diff --git a/flutter/lib/src/user_interaction/user_interaction_info.dart b/flutter/lib/src/user_interaction/user_interaction_info.dart new file mode 100644 index 0000000000..38ceb8f6a4 --- /dev/null +++ b/flutter/lib/src/user_interaction/user_interaction_info.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class UserInteractionInfo { + final Element element; + final String type; + + const UserInteractionInfo({ + required this.element, + required this.type, + }); +} diff --git a/flutter/lib/src/user_interaction/user_interaction_widget.dart b/flutter/lib/src/user_interaction/user_interaction_widget.dart deleted file mode 100644 index 58d7d18e79..0000000000 --- a/flutter/lib/src/user_interaction/user_interaction_widget.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class UserInteractionWidget { - final Element element; - final String description; - final String type; - final String eventType; - - const UserInteractionWidget({ - required this.element, - required this.description, - required this.type, - required this.eventType, - }); -} diff --git a/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart b/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart index 8f326579f4..b0288729b2 100644 --- a/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart +++ b/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart @@ -109,13 +109,24 @@ void main() { await tapMe(tester, sut, 'btn_1'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.category, 'ui.click'); - expect(crumb?.data?['view.id'], 'btn_1'); - expect(crumb?.data?['view.class'], 'MaterialButton'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_1', 'element': 'MaterialButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'view.id': 'btn_1', + 'view.class': 'MaterialButton', + })); }); }); @@ -125,11 +136,25 @@ void main() { await tapMe(tester, sut, 'btn_1'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'Button 1'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_1', 'element': 'MaterialButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'Button 1', + 'view.id': 'btn_1', + 'view.class': 'MaterialButton' + })); }); }); @@ -139,11 +164,25 @@ void main() { await tapMe(tester, sut, 'btn_3'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'My Icon'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_3', 'element': 'IconButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'My Icon', + 'view.id': 'btn_3', + 'view.class': 'IconButton' + })); }); }); @@ -153,11 +192,25 @@ void main() { await tapMe(tester, sut, 'btn_2'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'Button 2'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_2', 'element': 'CupertinoButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'Button 2', + 'view.id': 'btn_2', + 'view.class': 'CupertinoButton' + })); }); }); @@ -183,11 +236,25 @@ void main() { await tapMe(tester, sut, 'btn_5'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'Button 5'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_5', 'element': 'ButtonStyleButton'}, + {'element': 'Stack'}, + {'element': 'Listener'}, + {'element': 'RawGestureDetector'}, + {'name': 'btn_4', 'element': 'GestureDetector'}, + {'element': 'Semantics'}, + {'element': 'DefaultTextStyle'}, + {'element': 'AnimatedDefaultTextStyle'}, + {'element': 'NotificationListener'}, + {'element': 'CustomPaint'} + ], + 'label': 'Button 5', + 'view.id': 'btn_5', + 'view.class': 'ButtonStyleButton' + })); }); }); @@ -197,13 +264,24 @@ void main() { await tapMe(tester, sut, 'popup_menu_button'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.category, 'ui.click'); - expect(crumb?.data?['view.id'], 'popup_menu_button'); - expect(crumb?.data?['view.class'], 'PopupMenuButton'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'popup_menu_button', 'element': 'PopupMenuButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'view.id': 'popup_menu_button', + 'view.class': 'PopupMenuButton' + })); }); }); @@ -217,13 +295,90 @@ void main() { await tapMe(tester, sut, 'popup_menu_item_1'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.category, 'ui.click'); - expect(crumb?.data?['view.id'], 'popup_menu_item_1'); - expect(crumb?.data?['view.class'], 'PopupMenuItem'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'popup_menu_item_1', 'element': 'PopupMenuItem'}, + {'name': '[GlobalKey#00000]', 'element': 'FadeTransition'}, + {'element': 'ListBody'}, + {'element': 'Padding'}, + {'name': '[GlobalKey#00000]', 'element': 'IgnorePointer'}, + {'element': 'Semantics'}, + {'element': 'Listener'}, + { + 'name': '[LabeledGlobalKey#00000]', + 'element': 'RawGestureDetector' + }, + {'element': 'Listener'}, + {'element': 'NotificationListener'} + ], + 'view.id': 'popup_menu_item_1', + 'view.class': 'PopupMenuItem' + })); + }); + }); + + testWidgets('Add crumb for button with tooltip', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + // open the popup menu and wait for the animation to complete + await tapMe(tester, sut, 'tooltip_button'); + + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'tooltip_button', 'element': 'ButtonStyleButton'}, + {'element': 'Semantics'}, + {'element': 'Listener'}, + {'element': 'OverlayPortal'}, + {'element': 'Tooltip', 'label': 'Tooltip message.'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'} + ], + 'label': 'Button text', + 'view.id': 'tooltip_button', + 'view.class': 'ButtonStyleButton' + })); + }); + }); + + testWidgets('Add crumb for button without key', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + await tester.pumpWidget(sut); + await tester.tap(find.byElementPredicate((element) { + final widget = element.widget; + if (widget is MaterialButton) { + return (widget.child as Text).data == 'Button 5'; + } + return false; + })); + + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'element': 'MaterialButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'Button 5', + 'view.class': 'MaterialButton' + })); }); }); }); @@ -365,7 +520,6 @@ Future tapMe( if (pumpWidget) { await tester.pumpWidget(widget); } - await tester.tap(find.byKey(Key(key))); } @@ -394,6 +548,14 @@ class Fixture { child: MyApp(), ); } + + Breadcrumb getBreadcrumb() { + late final Breadcrumb crumb; + hub.configureScope((scope) { + crumb = scope.breadcrumbs.last; + }); + return crumb; + } } class MyApp extends StatelessWidget { @@ -420,23 +582,17 @@ class Page1 extends StatelessWidget { children: [ MaterialButton( key: Key('btn_1'), - onPressed: () { - // print('button pressed'); - }, + onPressed: () {}, child: const Text('Button 1'), ), CupertinoButton( key: Key('btn_2'), - onPressed: () { - // print('button pressed 2'); - }, + onPressed: () {}, child: const Text('Button 2'), ), IconButton( key: Key('btn_3'), - onPressed: () { - // print('button pressed 3'); - }, + onPressed: () {}, icon: Icon( Icons.dark_mode, semanticLabel: 'My Icon', @@ -445,17 +601,13 @@ class Page1 extends StatelessWidget { Card( child: GestureDetector( key: Key('btn_4'), - onTap: () => { - // print('button pressed 4'), - }, + onTap: () => {}, child: Stack( children: [ //fancy card layout ElevatedButton( key: Key('btn_5'), - onPressed: () => { - // print('button pressed 5'), - }, + onPressed: () => {}, child: const Text('Button 5'), ), ], @@ -478,6 +630,18 @@ class Page1 extends StatelessWidget { ), ], ), + Tooltip( + message: 'Tooltip message.', + child: ElevatedButton( + key: ValueKey('tooltip_button'), + onPressed: () {}, + child: Text('Button text'), + ), + ), + MaterialButton( + onPressed: () {}, + child: const Text('Button 5'), + ), ], ), ), @@ -496,9 +660,7 @@ class Page2 extends StatelessWidget { children: [ MaterialButton( key: Key('btn_page_2'), - onPressed: () { - // print('button page 2 pressed'); - }, + onPressed: () {}, child: const Text('Button Page 2'), ), ], @@ -507,3 +669,34 @@ class Page2 extends StatelessWidget { ); } } + +extension on String { + String replaceHashCodes() => replaceAll(RegExp(r'#[\da-fA-F]{5}'), '#00000'); +} + +extension on Map { + Map replaceHashCodes() => map((key, value) { + if (value is String) { + value = value.replaceHashCodes(); + } else if (value is Map) { + value = value.replaceHashCodes(); + } else if (value is List) { + value = value.replaceHashCodes(); + } + return MapEntry(key, value); + }); +} + +extension on List { + Iterable replaceHashCodes() => map((value) { + if (value is String) { + return value.replaceHashCodes(); + } else if (value is Map) { + return value.replaceHashCodes(); + } else if (value is List) { + return value.replaceHashCodes(); + } else { + return value; + } + }); +}