diff --git a/dart/lib/src/hint.dart b/dart/lib/src/hint.dart index bfe0dd9b38..f88be70a65 100644 --- a/dart/lib/src/hint.dart +++ b/dart/lib/src/hint.dart @@ -25,6 +25,8 @@ class Hint { SentryAttachment? screenshot; + SentryAttachment? viewHierarchy; + Hint(); factory Hint.withMap(Map map) { @@ -39,6 +41,12 @@ class Hint { return hint; } + factory Hint.withViewHierarchy(SentryAttachment viewHierarchy) { + final hint = Hint(); + hint.viewHierarchy = viewHierarchy; + return hint; + } + // Objects void addAll(Map keysAndValues) { diff --git a/dart/lib/src/protocol.dart b/dart/lib/src/protocol.dart index 26a4fc2118..3dac1a6f3c 100644 --- a/dart/lib/src/protocol.dart +++ b/dart/lib/src/protocol.dart @@ -38,3 +38,6 @@ export 'protocol/sentry_trace_header.dart'; export 'protocol/sentry_transaction_name_source.dart'; export 'protocol/sentry_baggage_header.dart'; export 'protocol/sentry_transaction_info.dart'; +// view hierarchy +export 'protocol/sentry_view_hierarchy.dart'; +export 'protocol/sentry_view_hierarchy_element.dart'; diff --git a/dart/lib/src/protocol/sentry_view_hierarchy.dart b/dart/lib/src/protocol/sentry_view_hierarchy.dart new file mode 100644 index 0000000000..f97410f7ac --- /dev/null +++ b/dart/lib/src/protocol/sentry_view_hierarchy.dart @@ -0,0 +1,20 @@ +import 'package:meta/meta.dart'; + +import 'sentry_view_hierarchy_element.dart'; + +@immutable +class SentryViewHierarchy { + SentryViewHierarchy(this.renderingSystem); + + final String renderingSystem; + final List windows = []; + + /// Header encoded as JSON + Map toJson() { + return { + 'rendering_system': renderingSystem, + if (windows.isNotEmpty) + 'windows': windows.map((e) => e.toJson()).toList(growable: false), + }; + } +} diff --git a/dart/lib/src/protocol/sentry_view_hierarchy_element.dart b/dart/lib/src/protocol/sentry_view_hierarchy_element.dart new file mode 100644 index 0000000000..5a4ae777e4 --- /dev/null +++ b/dart/lib/src/protocol/sentry_view_hierarchy_element.dart @@ -0,0 +1,55 @@ +import 'package:meta/meta.dart'; + +@immutable +class SentryViewHierarchyElement { + SentryViewHierarchyElement( + this.type, { + this.depth, + this.identifier, + this.width, + this.height, + this.x, + this.y, + this.z, + this.visible, + this.alpha, + this.extra, + }); + + final String type; + final int? depth; + final String? identifier; + final List children = []; + final double? width; + final double? height; + final double? x; + final double? y; + final double? z; + final bool? visible; + final double? alpha; + final Map? extra; + + /// Header encoded as JSON + Map toJson() { + final jsonMap = { + 'type': type, + if (depth != null) 'depth': depth, + if (identifier != null) 'identifier': identifier, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (x != null) 'x': x, + if (y != null) 'y': y, + if (z != null) 'z': z, + if (visible != null) 'visible': visible, + if (alpha != null) 'alpha': alpha, + if (children.isNotEmpty) + 'children': children.map((e) => e.toJson()).toList(growable: false), + }; + + if (extra?.isNotEmpty ?? false) { + jsonMap.addAll(extra!); + } + + return jsonMap; + } +} diff --git a/dart/lib/src/sentry_attachment/sentry_attachment.dart b/dart/lib/src/sentry_attachment/sentry_attachment.dart index ab10269cea..7214c3d157 100644 --- a/dart/lib/src/sentry_attachment/sentry_attachment.dart +++ b/dart/lib/src/sentry_attachment/sentry_attachment.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:typed_data'; +import '../protocol/sentry_view_hierarchy.dart'; +import '../utils.dart'; + // https://develop.sentry.dev/sdk/features/#attachments // https://develop.sentry.dev/sdk/envelopes/#attachment @@ -28,6 +31,8 @@ class SentryAttachment { /// breadcrumbs. static const String typeUnrealLogs = 'unreal.logs'; + static const String typeViewHierarchy = 'event.view_hierarchy'; + SentryAttachment.fromLoader({ required ContentLoader loader, required this.filename, @@ -88,6 +93,15 @@ class SentryAttachment { contentType: 'image/png', attachmentType: SentryAttachment.typeAttachmentDefault); + SentryAttachment.fromViewHierarchy(SentryViewHierarchy sentryViewHierarchy) + : this.fromLoader( + loader: () => Uint8List.fromList( + utf8JsonEncoder.convert(sentryViewHierarchy.toJson())), + filename: 'view-hierarchy.json', + contentType: 'application/json', + attachmentType: SentryAttachment.typeViewHierarchy, + ); + /// Attachment type. /// Should be one of types given in [AttachmentType]. final String attachmentType; diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 77ee2f586a..5988999673 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -140,6 +140,11 @@ class SentryClient { attachments.add(screenshot); } + var viewHierarchy = hint.viewHierarchy; + if (viewHierarchy != null) { + attachments.add(viewHierarchy); + } + final envelope = SentryEnvelope.fromEvent( preparedEvent, _options.sdk, diff --git a/dart/test/protocol/sentry_view_hierarchy_element_test.dart b/dart/test/protocol/sentry_view_hierarchy_element_test.dart new file mode 100644 index 0000000000..1d6e753acd --- /dev/null +++ b/dart/test/protocol/sentry_view_hierarchy_element_test.dart @@ -0,0 +1,65 @@ +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + group('json', () { + test('toJson with children', () { + final element = SentryViewHierarchyElement( + 'RenderObjectToWidgetAdapter', + depth: 1, + identifier: 'RenderView#a2216', + width: 100, + height: 200, + x: 100, + y: 50, + z: 30, + visible: true, + alpha: 90, + extra: {'key': 'value'}, + ); + final element2 = SentryViewHierarchyElement( + 'SentryScreenshotWidget', + depth: 2, + ); + element.children.add(element2); + + final map = element.toJson(); + + expect(map, { + 'type': 'RenderObjectToWidgetAdapter', + 'depth': 1, + 'identifier': 'RenderView#a2216', + 'children': [ + { + 'type': 'SentryScreenshotWidget', + 'depth': 2, + }, + ], + 'width': 100, + 'height': 200, + 'x': 100, + 'y': 50, + 'z': 30, + 'visible': true, + 'alpha': 90, + 'key': 'value', + }); + }); + + test('toJson no children', () { + final element = SentryViewHierarchyElement( + 'RenderObjectToWidgetAdapter', + depth: 1, + identifier: 'RenderView#a2216', + ); + + final map = element.toJson(); + + expect(map, { + 'type': 'RenderObjectToWidgetAdapter', + 'depth': 1, + 'identifier': 'RenderView#a2216', + }); + }); + }); +} diff --git a/dart/test/protocol/sentry_view_hierarchy_test.dart b/dart/test/protocol/sentry_view_hierarchy_test.dart new file mode 100644 index 0000000000..cde2124682 --- /dev/null +++ b/dart/test/protocol/sentry_view_hierarchy_test.dart @@ -0,0 +1,52 @@ +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + group('json', () { + test('toJson with children', () { + final element = SentryViewHierarchyElement( + 'RenderObjectToWidgetAdapter', + depth: 1, + identifier: 'RenderView#a2216', + ); + + final element2 = SentryViewHierarchyElement( + 'SentryScreenshotWidget', + depth: 2, + ); + element.children.add(element2); + + final viewHierrchy = SentryViewHierarchy('flutter'); + viewHierrchy.windows.add(element); + + final map = viewHierrchy.toJson(); + + expect(map, { + 'rendering_system': 'flutter', + 'windows': [ + { + 'type': 'RenderObjectToWidgetAdapter', + 'depth': 1, + 'identifier': 'RenderView#a2216', + 'children': [ + { + 'type': 'SentryScreenshotWidget', + 'depth': 2, + }, + ] + }, + ], + }); + }); + + test('toJson no children', () { + final viewHierrchy = SentryViewHierarchy('flutter'); + + final map = viewHierrchy.toJson(); + + expect(map, { + 'rendering_system': 'flutter', + }); + }); + }); +} diff --git a/dart/test/sentry_attachment_test.dart b/dart/test/sentry_attachment_test.dart index eafe638d3d..578cb5ad9b 100644 --- a/dart/test/sentry_attachment_test.dart +++ b/dart/test/sentry_attachment_test.dart @@ -168,6 +168,16 @@ void main() { expect(attachment.filename, 'screenshot.png'); expect(attachment.addToTransactions, false); }); + + test('fromViewHierarchy', () async { + final view = SentryViewHierarchy('flutter'); + final attachment = SentryAttachment.fromViewHierarchy(view); + + expect(attachment.attachmentType, SentryAttachment.typeViewHierarchy); + expect(attachment.contentType, 'application/json'); + expect(attachment.filename, 'view-hierarchy.json'); + expect(attachment.addToTransactions, false); + }); }); } diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index bd8890c479..53c1122eb2 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1143,7 +1143,23 @@ void main() { final capturedEnvelope = (fixture.transport).envelopes.first; final attachmentItem = capturedEnvelope.items.firstWhereOrNull( (element) => element.header.type == SentryItemType.attachment); - expect(attachmentItem != null, true); + expect(attachmentItem?.header.fileName, 'screenshot.png'); + }); + + test('captureEvent adds viewHierarchy from hint', () async { + final client = fixture.getSut(); + final view = SentryViewHierarchy('flutter'); + final attachment = SentryAttachment.fromViewHierarchy(view); + final hint = Hint.withViewHierarchy(attachment); + + await client.captureEvent(fakeEvent, hint: hint); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final attachmentItem = capturedEnvelope.items.firstWhereOrNull( + (element) => element.header.type == SentryItemType.attachment); + + expect(attachmentItem?.header.attachmentType, + SentryAttachment.typeViewHierarchy); }); test('captureTransaction adds trace context', () async { diff --git a/flutter/example/integration_test/integration_test.dart b/flutter/example/integration_test/integration_test.dart index 918e1bb600..236f85073d 100644 --- a/flutter/example/integration_test/integration_test.dart +++ b/flutter/example/integration_test/integration_test.dart @@ -8,6 +8,7 @@ void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + // Using fake DSN for testing purposes. Future setupSentryAndApp(WidgetTester tester) async { await setupSentry(() async { await tester.pumpWidget(SentryScreenshotWidget( @@ -16,7 +17,7 @@ void main() { child: const MyApp(), ))); await tester.pumpAndSettle(); - }); + }, 'https://abc@def.ingest.sentry.io/1234567'); } // Tests diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index e7c2ac718a..3967ba572f 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -23,19 +23,21 @@ const String _exampleDsn = const _channel = MethodChannel('example.flutter.sentry.io'); Future main() async { - await setupSentry(() => runApp( - SentryScreenshotWidget( - child: SentryUserInteractionWidget( - child: DefaultAssetBundle( - bundle: SentryAssetBundle(enableStructuredDataTracing: true), - child: const MyApp(), + await setupSentry( + () => runApp( + SentryScreenshotWidget( + child: SentryUserInteractionWidget( + child: DefaultAssetBundle( + bundle: SentryAssetBundle(enableStructuredDataTracing: true), + child: const MyApp(), + ), + ), ), ), - ), - )); + _exampleDsn); } -Future setupSentry(AppRunner appRunner) async { +Future setupSentry(AppRunner appRunner, String dsn) async { await SentryFlutter.init((options) { options.dsn = _exampleDsn; options.tracesSampleRate = 1.0; @@ -50,6 +52,7 @@ Future setupSentry(AppRunner appRunner) async { options.enableNdkScopeSync = true; options.enableUserInteractionTracing = true; options.attachScreenshot = true; + options.attachViewHierarchy = true; // We can enable Sentry debug logging during development. This is likely // going to log too much for your app, but can be useful when figuring out // configuration issues, e.g. finding out why your events are not uploaded. @@ -58,7 +61,6 @@ Future setupSentry(AppRunner appRunner) async { options.captureFailedHttpRequests = true; options.maxRequestBodySize = MaxRequestBodySize.always; options.maxResponseBodySize = MaxResponseBodySize.always; - options.captureFailedHttpRequests = true; }, // Init your App. appRunner: appRunner); @@ -222,6 +224,17 @@ class MainScaffold extends StatelessWidget { }, child: const Text('Capture from PlatformDispatcher.onError'), ), + ElevatedButton( + key: const Key('view hierarchy'), + onPressed: () => {}, + child: const Visibility( + visible: true, + child: Opacity( + opacity: 0.5, + child: Text('view hierarchy'), + ), + ), + ), ElevatedButton( onPressed: () => makeWebRequest(context), child: const Text('Dart: Web request'), diff --git a/flutter/lib/src/integrations/screenshot_integration.dart b/flutter/lib/src/integrations/screenshot_integration.dart index da8b5c9c51..fb062a9e08 100644 --- a/flutter/lib/src/integrations/screenshot_integration.dart +++ b/flutter/lib/src/integrations/screenshot_integration.dart @@ -7,7 +7,7 @@ import '../sentry_flutter_options.dart'; /// Adds [ScreenshotEventProcessor] to options event processors if [attachScreenshot] is true class ScreenshotIntegration implements Integration { SentryFlutterOptions? _options; - ScreenshotEventProcessor? screenshotEventProcessor; + ScreenshotEventProcessor? _screenshotEventProcessor; @override FutureOr call(Hub hub, SentryFlutterOptions options) { @@ -15,17 +15,17 @@ class ScreenshotIntegration implements Integration { _options = options; final screenshotEventProcessor = ScreenshotEventProcessor(options); options.addEventProcessor(screenshotEventProcessor); - this.screenshotEventProcessor = screenshotEventProcessor; + _screenshotEventProcessor = screenshotEventProcessor; options.sdk.addIntegration('screenshotIntegration'); } } @override FutureOr close() { - final screenshotEventProcessor = this.screenshotEventProcessor; + final screenshotEventProcessor = _screenshotEventProcessor; if (screenshotEventProcessor != null) { _options?.removeEventProcessor(screenshotEventProcessor); - this.screenshotEventProcessor = null; + _screenshotEventProcessor = null; } } } diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index fd66752620..6cf8dd6391 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -19,6 +19,7 @@ import 'event_processor/flutter_enricher_event_processor.dart'; import 'file_system_transport.dart'; import 'version.dart'; +import 'view_hierarchy/view_hierarchy_integration.dart'; /// Configuration options callback typedef FlutterOptionsConfiguration = FutureOr Function( @@ -158,6 +159,9 @@ mixin SentryFlutter { integrations.add(ScreenshotIntegration()); } + // works with Skia, CanvasKit and HTML renderer + integrations.add(SentryViewHierarchyIntegration()); + integrations.add(DebugPrintIntegration()); // This is an Integration because we want to execute it after all the diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 4206c60a36..2aa116c8d0 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -207,6 +207,13 @@ class SentryFlutterOptions extends SentryOptions { @internal late RendererWrapper rendererWrapper = RendererWrapper(); + /// Enables the View Hierarchy feature. + /// + /// Renders an ASCII represention of the entire view hierarchy of the + /// application when an error happens and includes it as an attachment. + @experimental + bool attachViewHierarchy = false; + /// By using this, you are disabling native [Breadcrumb] tracking and instead /// you are just tracking [Breadcrumb]s which result from events available /// in the current Flutter environment. 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 c9155418b2..cc86b06183 100644 --- a/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart +++ b/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; +import '../widget_utils.dart'; import 'user_interaction_widget.dart'; // Adapted from https://github.com/ueman/sentry-dart-tools/blob/8e41418c0f2c62dc88292cf32a4f22e79112b744/sentry_flutter_plus/lib/src/widgets/click_tracker.dart @@ -109,7 +110,7 @@ class _SentryUserInteractionWidgetState void _onTappedAt(Offset position) { final tappedWidget = _getElementAt(position); - final keyValue = tappedWidget?.keyValue; + final keyValue = tappedWidget?.element.widget.key?.toStringValue(); if (tappedWidget == null || keyValue == null) { return; } diff --git a/flutter/lib/src/user_interaction/user_interaction_widget.dart b/flutter/lib/src/user_interaction/user_interaction_widget.dart index f0c5e3b66d..58d7d18e79 100644 --- a/flutter/lib/src/user_interaction/user_interaction_widget.dart +++ b/flutter/lib/src/user_interaction/user_interaction_widget.dart @@ -12,17 +12,4 @@ class UserInteractionWidget { required this.type, required this.eventType, }); - - String? get keyValue { - final key = element.widget.key; - if (key == null) { - return null; - } - if (key is ValueKey) { - return key.value; - } else if (key is ValueKey) { - return key.value?.toString(); - } - return key.toString(); - } } diff --git a/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart b/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart new file mode 100644 index 0000000000..4da7a0d905 --- /dev/null +++ b/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart @@ -0,0 +1,104 @@ +import 'package:flutter/widgets.dart'; + +import '../../sentry_flutter.dart'; +import '../widget_utils.dart'; + +// adapted from https://github.com/ueman/sentry-dart-tools/blob/8e41418c0f2c62dc88292cf32a4f22e79112b744/sentry_flutter_plus/lib/src/integrations/tree_walker_integration.dart + +class _TreeWalker { + static const _privateDelimiter = '_'; + + _TreeWalker(this.rootElement); + + final Element rootElement; + + ValueChanged _visitor( + SentryViewHierarchyElement parentSentryElement) { + return (Element element) { + final sentryElement = _toSentryViewHierarchyElement(element); + + var privateElement = false; + // when obfuscation is enabled, this won't work because all the types + // are renamed + if (sentryElement.type.startsWith(_privateDelimiter) || + (sentryElement.identifier?.startsWith(_privateDelimiter) ?? false)) { + privateElement = true; + } else { + parentSentryElement.children.add(sentryElement); + } + + // we don't want to add private children but we still want to walk the tree + element.visitChildElements( + _visitor(privateElement ? parentSentryElement : sentryElement)); + }; + } + + SentryViewHierarchy? toSentryViewHierarchy() { + final sentryRootElement = _toSentryViewHierarchyElement(rootElement); + rootElement.visitChildElements(_visitor(sentryRootElement)); + + final sentryViewHierarchy = SentryViewHierarchy('flutter'); + sentryViewHierarchy.windows.add(sentryRootElement); + return sentryViewHierarchy; + } + + SentryViewHierarchyElement _toSentryViewHierarchyElement(Element element) { + final widget = element.widget; + + double? width; + double? height; + double? x; + double? y; + bool? visible; + double? alpha; + + // Widget has to be RenderBox to have a size + if (widget is RenderBox) { + final size = element.size; + width = size?.width; + height = size?.height; + } + + final renderObject = element.renderObject; + if (renderObject is RenderBox) { + final offset = renderObject.localToGlobal(Offset.zero); + if (offset.dx > 0) { + x = offset.dx; + } + if (offset.dy > 0) { + y = offset.dy; + } + // no z axes in 2d + } + + if (widget is Visibility) { + visible = widget.visible; + } + if (widget is Opacity) { + alpha = widget.opacity; + } + + return SentryViewHierarchyElement( + element.widget.runtimeType.toString(), + depth: element.depth, + identifier: element.widget.key?.toStringValue(), + width: width, + height: height, + x: x, + y: y, + visible: visible, + alpha: alpha, + ); + } +} + +SentryViewHierarchy? walkWidgetTree(WidgetsBinding instance) { + final rootElement = instance.renderViewElement; + if (rootElement == null) { + return null; + } + + final walker = _TreeWalker(rootElement); + + return walker.toSentryViewHierarchy(); +} diff --git a/flutter/lib/src/view_hierarchy/view_hierarchy_event_processor.dart b/flutter/lib/src/view_hierarchy/view_hierarchy_event_processor.dart new file mode 100644 index 0000000000..832b004173 --- /dev/null +++ b/flutter/lib/src/view_hierarchy/view_hierarchy_event_processor.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import '../../sentry_flutter.dart'; +import 'sentry_tree_walker.dart'; + +/// A [EventProcessor] that renders an ASCII represention of the entire view +/// hierarchy of the application when an error happens and includes it as an +/// attachment to the [Hint]. +class SentryViewHierarchyEventProcessor implements EventProcessor { + SentryViewHierarchyEventProcessor(this._options); + + final SentryFlutterOptions _options; + + @override + FutureOr apply(SentryEvent event, {Hint? hint}) async { + if (event.exceptions == null && event.throwable == null) { + return event; + } + + final instance = _options.bindingUtils.instance; + if (instance == null) { + return event; + } + final sentryViewHierarchy = walkWidgetTree(instance); + + if (sentryViewHierarchy == null) { + return event; + } + + final viewHierarchy = + SentryAttachment.fromViewHierarchy(sentryViewHierarchy); + hint?.viewHierarchy = viewHierarchy; + return event; + } +} diff --git a/flutter/lib/src/view_hierarchy/view_hierarchy_integration.dart b/flutter/lib/src/view_hierarchy/view_hierarchy_integration.dart new file mode 100644 index 0000000000..85160533cf --- /dev/null +++ b/flutter/lib/src/view_hierarchy/view_hierarchy_integration.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import '../../sentry_flutter.dart'; +import 'view_hierarchy_event_processor.dart'; + +/// A [Integration] that renders an ASCII represention of the entire view +/// hierarchy of the application when an error happens and includes it as an +/// attachment to the [Hint]. +class SentryViewHierarchyIntegration extends Integration { + SentryViewHierarchyEventProcessor? _eventProcessor; + SentryFlutterOptions? _options; + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) { + if (!options.attachViewHierarchy) { + return Future.value(); + } + _options = options; + final eventProcessor = SentryViewHierarchyEventProcessor(options); + options.addEventProcessor(eventProcessor); + _eventProcessor = eventProcessor; + options.sdk.addIntegration('viewHierarchyIntegration'); + } + + @override + FutureOr close() { + final eventProcessor = _eventProcessor; + if (eventProcessor != null) { + _options?.removeEventProcessor(eventProcessor); + _eventProcessor = null; + } + } +} diff --git a/flutter/lib/src/widget_utils.dart b/flutter/lib/src/widget_utils.dart new file mode 100644 index 0000000000..9d63b8d5a3 --- /dev/null +++ b/flutter/lib/src/widget_utils.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; + +extension WidgetExtension on Key { + String? toStringValue() { + final key = this; + if (key is ValueKey) { + return key.value; + } else if (key is ValueKey) { + return key.value?.toString(); + } else if (key is GlobalObjectKey) { + return key.value.toString(); + } else if (key is ObjectKey) { + return key.value?.toString(); + } + return key.toString(); + } +} diff --git a/flutter/test/integrations/screenshot_integration_test.dart b/flutter/test/integrations/screenshot_integration_test.dart index 8e31a107fb..f59452fdeb 100644 --- a/flutter/test/integrations/screenshot_integration_test.dart +++ b/flutter/test/integrations/screenshot_integration_test.dart @@ -36,6 +36,25 @@ void main() { expect(processors.isEmpty, true); }); + test('screenshotIntegration adds integration to the sdk list', () async { + final integration = fixture.getSut(); + + await integration(fixture.hub, fixture.options); + + expect(fixture.options.sdk.integrations.contains('screenshotIntegration'), + true); + }); + + test('screenshotIntegration does not add integration to the sdk list', + () async { + final integration = fixture.getSut(attachScreenshot: false); + + await integration(fixture.hub, fixture.options); + + expect(fixture.options.sdk.integrations.contains('screenshotIntegration'), + false); + }); + test('screenshotIntegration close resets processor', () async { final integration = fixture.getSut(); diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index 8c3c0d3382..c67ebf4acf 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -8,6 +8,7 @@ import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; import 'package:sentry_flutter/src/renderer/renderer.dart'; import 'package:sentry_flutter/src/sentry_native.dart'; import 'package:sentry_flutter/src/version.dart'; +import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_integration.dart'; import 'mocks.dart'; import 'mocks.mocks.dart'; import 'sentry_flutter_util.dart'; @@ -19,6 +20,7 @@ final platformAgnosticIntegrations = [ FlutterErrorIntegration, LoadReleaseIntegration, DebugPrintIntegration, + SentryViewHierarchyIntegration, ]; final nonWebIntegrations = [ diff --git a/flutter/test/view_hierarchy/sentry_tree_walker_test.dart b/flutter/test/view_hierarchy/sentry_tree_walker_test.dart new file mode 100644 index 0000000000..c2048c3492 --- /dev/null +++ b/flutter/test/view_hierarchy/sentry_tree_walker_test.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_flutter/src/view_hierarchy/sentry_tree_walker.dart'; + +void main() { + group('TreeWalker', () { + late WidgetsBinding instance; + + setUp(() { + instance = TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('returns a SentryViewHierarchy with flutter render', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final sentryViewHierarchy = walkWidgetTree(instance); + + expect(sentryViewHierarchy!.renderingSystem, 'flutter'); + }); + }); + + testWidgets('returns a SentryViewHierarchyElement with a type', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + true, + _findWidget(first, (element) { + return element.type == 'MaterialApp'; + })); + }); + }); + + testWidgets('returns a SentryViewHierarchyElement with a depth', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + true, + _findWidget(first, (element) { + return element.depth != null; + })); + }); + }); + + testWidgets('returns a SentryViewHierarchyElement with a identifier', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + true, + _findWidget(first, (element) { + return element.identifier == 'btn_1'; + })); + }); + }); + + testWidgets('returns a SentryViewHierarchyElement with X and Y', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + true, + _findWidget(first, (element) { + return element.x != null && element.y != null; + })); + }); + }); + + testWidgets('returns a SentryViewHierarchyElement with visibility', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + true, + _findWidget(first, (element) { + return element.visible == true; + })); + }); + }); + + testWidgets( + 'does not return a SentryViewHierarchyElement without visibility', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + false, + _findWidget(first, (element) { + return element.visible == false; + })); + }); + }); + + testWidgets('returns a SentryViewHierarchyElement with alpha', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + true, + _findWidget(first, (element) { + return element.alpha == 0.5; + })); + }); + }); + + testWidgets('does not return a SentryViewHierarchyElement with private key', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MyApp()); + + final first = _getFirstSentryViewHierarchy(instance); + + expect( + false, + _findWidget(first, (element) { + return element.identifier == '_btn_3'; + })); + }); + }); + }); +} + +SentryViewHierarchyElement _getFirstSentryViewHierarchy( + WidgetsBinding instance) { + final sentryViewHierarchy = walkWidgetTree(instance); + + return sentryViewHierarchy!.windows.first; +} + +bool _findWidget( + SentryViewHierarchyElement element, + bool Function(SentryViewHierarchyElement element) predicate, +) { + if (predicate(element)) { + return true; + } + + if (element.children.isNotEmpty) { + for (final child in element.children) { + if (_findWidget(child, predicate)) { + return true; + } + } + } + + return false; +} + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Welcome to Flutter', + home: Scaffold( + appBar: AppBar( + title: const Text('Welcome to Flutter'), + ), + body: Center( + child: Column( + children: [ + MaterialButton( + key: Key('btn_1'), + onPressed: () {}, + child: const Text('Button 1'), + ), + MaterialButton( + key: Key('btn_2'), + onPressed: () {}, + child: Visibility( + key: Key('btn_2_visibility'), + visible: true, + child: Opacity( + key: Key('btn_2_opacity'), + opacity: 0.5, + child: const Text('Button 2'), + ), + )), + MaterialButton( + key: Key('_btn_3'), + onPressed: () {}, + child: const Text('Button 3'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/test/view_hierarchy/view_hierarchy_event_processor_test.dart b/flutter/test/view_hierarchy/view_hierarchy_event_processor_test.dart new file mode 100644 index 0000000000..a4c081f2c8 --- /dev/null +++ b/flutter/test/view_hierarchy/view_hierarchy_event_processor_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_flutter/src/binding_wrapper.dart'; +import 'package:sentry_flutter/src/sentry_flutter_options.dart'; +import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_event_processor.dart'; + +void main() { + group(SentryViewHierarchyEventProcessor, () { + late Fixture fixture; + late WidgetsBinding instance; + + setUp(() { + fixture = Fixture(); + instance = TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('adds view hierarchy to hint only for event with exception', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(instance); + + await tester.pumpWidget(MyApp()); + + final event = SentryEvent( + exceptions: [SentryException(type: 'type', value: 'value')]); + final hint = Hint(); + + await sut.apply(event, hint: hint); + + expect(hint.viewHierarchy, isNotNull); + }); + }); + + testWidgets('adds view hierarchy to hint only for event with throwable', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(instance); + + await tester.pumpWidget(MyApp()); + + final event = SentryEvent(throwable: StateError('error')); + final hint = Hint(); + + await sut.apply(event, hint: hint); + + expect(hint.viewHierarchy, isNotNull); + }); + }); + + testWidgets('does not add view hierarchy to hint if not an error', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(instance); + + await tester.pumpWidget(MyApp()); + + final event = SentryEvent(); + final hint = Hint(); + + await sut.apply(event, hint: hint); + + expect(hint.viewHierarchy, isNull); + }); + }); + + testWidgets('does not add view hierarchy if widget returns null', + (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(instance); + + // does not pumpWidget + + final event = SentryEvent(); + final hint = Hint(); + + await sut.apply(event, hint: hint); + + expect(hint.viewHierarchy, isNull); + }); + }); + }); +} + +class TestBindingWrapper implements BindingWrapper { + TestBindingWrapper(this._binding); + + final WidgetsBinding _binding; + + @override + WidgetsBinding ensureInitialized() { + return TestWidgetsFlutterBinding.ensureInitialized(); + } + + @override + WidgetsBinding get instance { + return _binding; + } +} + +class Fixture { + SentryViewHierarchyEventProcessor getSut(WidgetsBinding instance) { + final options = SentryFlutterOptions() + ..bindingUtils = TestBindingWrapper(instance); + return SentryViewHierarchyEventProcessor(options); + } +} + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Welcome to Flutter', + home: Scaffold( + appBar: AppBar( + title: const Text('Welcome to Flutter'), + ), + ), + ); + } +} diff --git a/flutter/test/view_hierarchy/view_hierarchy_integration_test.dart b/flutter/test/view_hierarchy/view_hierarchy_integration_test.dart new file mode 100644 index 0000000000..c6c93d9216 --- /dev/null +++ b/flutter/test/view_hierarchy/view_hierarchy_integration_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_event_processor.dart'; +import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_integration.dart'; + +import '../mocks.mocks.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('viewHierarchyIntegration creates view hierarchy processor', () async { + final integration = fixture.getSut(); + + await integration(fixture.hub, fixture.options); + + final processors = fixture.options.eventProcessors + .where((e) => e.runtimeType == SentryViewHierarchyEventProcessor); + + expect(processors.isNotEmpty, true); + }); + + test( + 'viewHierarchyIntegration does not add view hierarchy processor if opt out in options', + () async { + final integration = fixture.getSut(attachViewHierarchy: false); + + await integration(fixture.hub, fixture.options); + + final processors = fixture.options.eventProcessors + .where((e) => e.runtimeType == SentryViewHierarchyEventProcessor); + + expect(processors.isEmpty, true); + }); + + test('viewHierarchyIntegration close resets processor', () async { + final integration = fixture.getSut(); + + await integration(fixture.hub, fixture.options); + await integration.close(); + + final processors = fixture.options.eventProcessors + .where((e) => e.runtimeType == SentryViewHierarchyEventProcessor); + + expect(processors.isEmpty, true); + }); + + test('viewHierarchyIntegration adds integration to the sdk list', () async { + final integration = fixture.getSut(); + + await integration(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations.contains('viewHierarchyIntegration'), + true); + }); + + test('viewHierarchyIntegration does not add integration to the sdk list', + () async { + final integration = fixture.getSut(attachViewHierarchy: false); + + await integration(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations.contains('viewHierarchyIntegration'), + false); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryFlutterOptions(); + + SentryViewHierarchyIntegration getSut({bool attachViewHierarchy = true}) { + options.attachViewHierarchy = attachViewHierarchy; + return SentryViewHierarchyIntegration(); + } +} diff --git a/min_version_test/lib/main.dart b/min_version_test/lib/main.dart index 1a0aa4fbd0..2ad54d4287 100644 --- a/min_version_test/lib/main.dart +++ b/min_version_test/lib/main.dart @@ -39,6 +39,7 @@ Future setupSentry(AppRunner appRunner) async { options.enableNdkScopeSync = true; options.enableUserInteractionTracing = true; options.attachScreenshot = true; + options.attachViewHierarchy = true; // We can enable Sentry debug logging during development. This is likely // going to log too much for your app, but can be useful when figuring out // configuration issues, e.g. finding out why your events are not uploaded.