diff --git a/CHANGELOG.md b/CHANGELOG.md index 49313fb960..30d8755575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ - Emit `transaction.data` inside `contexts.trace.data` ([#2284](https://github.com/getsentry/sentry-dart/pull/2284)) - Blocking app starts if "appLaunchedInForeground" is false. (Android only) ([#2291](https://github.com/getsentry/sentry-dart/pull/2291)) - Windows native error & obfuscation support ([#2286](https://github.com/getsentry/sentry-dart/pull/2286)) +- Support `captureFeedback` ([#2230](https://github.com/getsentry/sentry-dart/pull/2230)) + - Deprecated `Sentry.captureUserFeedback`, use `captureFeedback` instead. + - Deprecated `Hub.captureUserFeedback`, use `captureFeedback` instead. + - Deprecated `SentryClient.captureUserFeedback`, use `captureFeedback` instead. + - Deprecated `SentryUserFeedback`, use `SentryFeedback` instead. +- Add `SentryFeedbackWidget` ([#2240](https://github.com/getsentry/sentry-dart/pull/2240)) +```dart +Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SentryFeedbackWidget(associatedEventId: id), + fullscreenDialog: true, + ), +); +``` ### Enhancements @@ -57,7 +72,6 @@ ``` - Support allowUrls and denyUrls for Flutter Web ([#2227](https://github.com/getsentry/sentry-dart/pull/2227)) - ```dart await SentryFlutter.init( (options) { @@ -68,7 +82,6 @@ appRunner: () => runApp(MyApp()), ); ``` - - Collect touch breadcrumbs for all buttons, not just those with `key` specified. ([#2242](https://github.com/getsentry/sentry-dart/pull/2242)) - Add `enableDartSymbolication` option to Sentry.init() for **Flutter iOS, macOS and Android** ([#2256](https://github.com/getsentry/sentry-dart/pull/2256)) - This flag enables symbolication of Dart stack traces when native debug images are not available. @@ -91,14 +104,12 @@ - Add `SentryFlutter.nativeCrash()` using MethodChannels for Android and iOS ([#2239](https://github.com/getsentry/sentry-dart/pull/2239)) - This can be used to test if native crash reporting works - - Add `ignoreRoutes` parameter to `SentryNavigatorObserver`. ([#2218](https://github.com/getsentry/sentry-dart/pull/2218)) - - This will ignore the Routes and prevent the Route from being pushed to the Sentry server. - - Ignored routes will also create no TTID and TTFD spans. - - ```dart - SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), - ``` + - This will ignore the Routes and prevent the Route from being pushed to the Sentry server. + - Ignored routes will also create no TTID and TTFD spans. +```dart +SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), +``` ### Improvements diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index e9cae9d666..ecb44288f1 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -58,3 +58,5 @@ export 'src/utils.dart'; export 'src/spotlight.dart'; // proxy export 'src/protocol/sentry_proxy.dart'; +// feedback +export 'src/protocol/sentry_feedback.dart'; diff --git a/dart/lib/src/hub.dart b/dart/lib/src/hub.dart index cbbc30dd36..4e2ca289c3 100644 --- a/dart/lib/src/hub.dart +++ b/dart/lib/src/hub.dart @@ -256,6 +256,8 @@ class Hub { return sentryId; } + @Deprecated( + 'Will be removed in a future version. Use [captureFeedback] instead') Future captureUserFeedback(SentryUserFeedback userFeedback) async { if (!_isEnabled) { _options.logger( @@ -288,6 +290,47 @@ class Hub { } } + /// Captures the feedback. + Future captureFeedback( + SentryFeedback feedback, { + Hint? hint, + ScopeCallback? withScope, + }) async { + var sentryId = SentryId.empty(); + + if (!_isEnabled) { + _options.logger( + SentryLevel.warning, + "Instance is disabled and this 'captureFeedback' call is a no-op.", + ); + } else { + final item = _peek(); + late Scope scope; + final s = _cloneAndRunWithScope(item.scope, withScope); + if (s is Future) { + scope = await s; + } else { + scope = s; + } + + try { + sentryId = await item.client.captureFeedback( + feedback, + hint: hint, + scope: scope, + ); + } catch (exception, stacktrace) { + _options.logger( + SentryLevel.error, + 'Error while capturing feedback', + exception: exception, + stackTrace: stacktrace, + ); + } + } + return sentryId; + } + FutureOr _cloneAndRunWithScope( Scope scope, ScopeCallback? withScope) async { if (withScope != null) { diff --git a/dart/lib/src/hub_adapter.dart b/dart/lib/src/hub_adapter.dart index 137e028f91..fa93aaf915 100644 --- a/dart/lib/src/hub_adapter.dart +++ b/dart/lib/src/hub_adapter.dart @@ -1,19 +1,20 @@ import 'dart:async'; import 'package:meta/meta.dart'; -import 'hint.dart'; +import 'hint.dart'; import 'hub.dart'; import 'metrics/metric.dart'; import 'metrics/metrics_aggregator.dart'; import 'metrics/metrics_api.dart'; import 'profiling.dart'; import 'protocol.dart'; +import 'protocol/sentry_feedback.dart'; import 'scope.dart'; import 'sentry.dart'; import 'sentry_client.dart'; -import 'sentry_user_feedback.dart'; import 'sentry_options.dart'; +import 'sentry_user_feedback.dart'; import 'tracing.dart'; /// Hub adapter to make Integrations testable @@ -118,7 +119,9 @@ class HubAdapter implements Hub { ISentrySpan? getSpan() => Sentry.currentHub.getSpan(); @override + // ignore: deprecated_member_use_from_same_package Future captureUserFeedback(SentryUserFeedback userFeedback) => + // ignore: deprecated_member_use_from_same_package Sentry.captureUserFeedback(userFeedback); @override @@ -197,4 +200,16 @@ class HubAdapter implements Hub { @override MetricsAggregator? get metricsAggregator => Sentry.currentHub.metricsAggregator; + + @override + Future captureFeedback( + SentryFeedback feedback, { + Hint? hint, + ScopeCallback? withScope, + }) => + Sentry.currentHub.captureFeedback( + feedback, + hint: hint, + withScope: withScope, + ); } diff --git a/dart/lib/src/noop_hub.dart b/dart/lib/src/noop_hub.dart index 30f68ca895..10f34a6d42 100644 --- a/dart/lib/src/noop_hub.dart +++ b/dart/lib/src/noop_hub.dart @@ -9,6 +9,7 @@ import 'metrics/metrics_aggregator.dart'; import 'metrics/metrics_api.dart'; import 'profiling.dart'; import 'protocol.dart'; +import 'protocol/sentry_feedback.dart'; import 'scope.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; @@ -96,8 +97,17 @@ class NoOpHub implements Hub { SentryId.empty(); @override + // ignore: deprecated_member_use_from_same_package Future captureUserFeedback(SentryUserFeedback userFeedback) async {} + @override + Future captureFeedback( + SentryFeedback feedback, { + Hint? hint, + ScopeCallback? withScope, + }) async => + SentryId.empty(); + @override ISentrySpan startTransaction( String name, diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 5a8c04bbae..f2854f4f7e 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -6,6 +6,7 @@ import 'hint.dart'; import 'metrics/metric.dart'; import 'metrics/metrics_aggregator.dart'; import 'protocol.dart'; +import 'protocol/sentry_feedback.dart'; import 'scope.dart'; import 'sentry_client.dart'; import 'sentry_envelope.dart'; @@ -55,6 +56,7 @@ class NoOpSentryClient implements SentryClient { SentryId.empty(); @override + // ignore: deprecated_member_use_from_same_package Future captureUserFeedback(SentryUserFeedback userFeedback) async {} @override @@ -77,4 +79,9 @@ class NoOpSentryClient implements SentryClient { @override @internal MetricsAggregator? get metricsAggregator => null; + + @override + Future captureFeedback(SentryFeedback feedback, + {Scope? scope, Hint? hint}) async => + SentryId.empty(); } diff --git a/dart/lib/src/protocol/contexts.dart b/dart/lib/src/protocol/contexts.dart index 42d96fcc7e..0a38e505af 100644 --- a/dart/lib/src/protocol/contexts.dart +++ b/dart/lib/src/protocol/contexts.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import '../protocol.dart'; +import 'sentry_feedback.dart'; /// The context interfaces provide additional context data. /// @@ -19,6 +20,7 @@ class Contexts extends MapView { SentryCulture? culture, SentryTraceContext? trace, SentryResponse? response, + SentryFeedback? feedback, }) : super({ SentryDevice.type: device, SentryOperatingSystem.type: operatingSystem, @@ -29,6 +31,7 @@ class Contexts extends MapView { SentryCulture.type: culture, SentryTraceContext.type: trace, SentryResponse.type: response, + SentryFeedback.type: feedback, }); /// Deserializes [Contexts] from JSON [Map]. @@ -62,6 +65,9 @@ class Contexts extends MapView { response: data[SentryResponse.type] != null ? SentryResponse.fromJson(Map.from(data[SentryResponse.type])) : null, + feedback: data[SentryFeedback.type] != null + ? SentryFeedback.fromJson(Map.from(data[SentryFeedback.type])) + : null, ); data.keys @@ -136,6 +142,11 @@ class Contexts extends MapView { set response(SentryResponse? value) => this[SentryResponse.type] = value; + /// Feedback context for a feedback event. + SentryFeedback? get feedback => this[SentryFeedback.type]; + + set feedback(SentryFeedback? value) => this[SentryFeedback.type] = value; + /// Produces a [Map] that can be serialized to JSON. Map toJson() { final json = {}; @@ -198,6 +209,13 @@ class Contexts extends MapView { } break; + case SentryFeedback.type: + final feedbackMap = feedback?.toJson(); + if (feedbackMap?.isNotEmpty ?? false) { + json[SentryFeedback.type] = feedbackMap; + } + break; + case SentryRuntime.listType: if (runtimes.length == 1) { final runtime = runtimes[0]; @@ -249,6 +267,7 @@ class Contexts extends MapView { trace: trace?.clone(), response: response?.clone(), runtimes: runtimes.map((runtime) => runtime.clone()).toList(), + feedback: feedback?.clone(), )..addEntries( entries.where((element) => !_defaultFields.contains(element.key)), ); @@ -266,6 +285,7 @@ class Contexts extends MapView { SentryGpu? gpu, SentryTraceContext? trace, SentryResponse? response, + SentryFeedback? feedback, }) => Contexts( device: device ?? this.device, @@ -277,6 +297,7 @@ class Contexts extends MapView { culture: culture ?? this.culture, trace: trace ?? this.trace, response: response ?? this.response, + feedback: feedback ?? this.feedback, )..addEntries( entries.where((element) => !_defaultFields.contains(element.key)), ); @@ -292,5 +313,6 @@ class Contexts extends MapView { SentryCulture.type, SentryTraceContext.type, SentryResponse.type, + SentryFeedback.type, ]; } diff --git a/dart/lib/src/protocol/sentry_feedback.dart b/dart/lib/src/protocol/sentry_feedback.dart new file mode 100644 index 0000000000..832db2316f --- /dev/null +++ b/dart/lib/src/protocol/sentry_feedback.dart @@ -0,0 +1,81 @@ +import 'package:meta/meta.dart'; + +import 'access_aware_map.dart'; +import 'sentry_id.dart'; + +@immutable +class SentryFeedback { + static const type = 'feedback'; + + SentryFeedback({ + required this.message, + this.contactEmail, + this.name, + this.replayId, + this.url, + this.associatedEventId, + this.unknown, + }); + + final String message; + final String? contactEmail; + final String? name; + final String? replayId; + final String? url; + final SentryId? associatedEventId; + + @internal + final Map? unknown; + + /// Deserializes a [SentryFeedback] from JSON [Map]. + factory SentryFeedback.fromJson(Map data) { + final json = AccessAwareMap(data); + + String? associatedEventId = json['associated_event_id']; + + return SentryFeedback( + message: json['message'], + contactEmail: json['contact_email'], + name: json['name'], + replayId: json['replay_id'], + url: json['url'], + associatedEventId: + associatedEventId != null ? SentryId.fromId(associatedEventId) : null, + unknown: json.notAccessed(), + ); + } + + Map toJson() { + return { + ...?unknown, + 'message': message, + if (contactEmail != null) 'contact_email': contactEmail, + if (name != null) 'name': name, + if (replayId != null) 'replay_id': replayId, + if (url != null) 'url': url, + if (associatedEventId != null) + 'associated_event_id': associatedEventId.toString(), + }; + } + + SentryFeedback copyWith({ + String? message, + String? contactEmail, + String? name, + String? replayId, + String? url, + SentryId? associatedEventId, + Map? unknown, + }) => + SentryFeedback( + message: message ?? this.message, + contactEmail: contactEmail ?? this.contactEmail, + name: name ?? this.name, + replayId: replayId ?? this.replayId, + url: url ?? this.url, + associatedEventId: associatedEventId ?? this.associatedEventId, + unknown: unknown ?? this.unknown, + ); + + SentryFeedback clone() => copyWith(); +} diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index 48a88bd469..a18e7b2fa1 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'dart_exception_type_identifier.dart'; import 'load_dart_debug_images_integration.dart'; import 'metrics/metrics_api.dart'; +import 'protocol/sentry_feedback.dart'; import 'run_zoned_guarded_integration.dart'; import 'event_processor/enricher/enricher_event_processor.dart'; import 'environment/environment_variables.dart'; @@ -22,6 +23,7 @@ import 'sentry_client.dart'; import 'sentry_options.dart'; import 'sentry_user_feedback.dart'; import 'tracing.dart'; +import 'sentry_attachment/sentry_attachment.dart'; /// Configuration options callback typedef OptionsConfiguration = FutureOr Function(SentryOptions); @@ -221,9 +223,21 @@ class Sentry { /// Reports a [userFeedback] to Sentry.io. /// /// First capture an event and use the [SentryId] to create a [SentryUserFeedback] + @Deprecated( + 'Will be removed in a future version. Use [captureFeedback] instead') static Future captureUserFeedback(SentryUserFeedback userFeedback) => _hub.captureUserFeedback(userFeedback); + /// Reports [SentryFeedback] to Sentry.io. + /// + /// Use [withScope] to add [SentryAttachment] to the feedback. + static Future captureFeedback( + SentryFeedback feedback, { + Hint? hint, + ScopeCallback? withScope, + }) => + _hub.captureFeedback(feedback, hint: hint, withScope: withScope); + /// Close the client SDK static Future close() async { final hub = _hub; diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 91aca8bcbb..43e5e0be9d 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -10,6 +10,7 @@ import 'hint.dart'; import 'metrics/metric.dart'; import 'metrics/metrics_aggregator.dart'; import 'protocol.dart'; +import 'protocol/sentry_feedback.dart'; import 'scope.dart'; import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_baggage.dart'; @@ -112,7 +113,7 @@ class SentryClient { return _emptySentryId; } - if (_sampleRate()) { + if (_sampleRate() && event.type != 'feedback') { _options.recorder .recordLostEvent(DiscardReason.sampleRate, _getCategory(event)); _options.logger( @@ -169,7 +170,7 @@ class SentryClient { } var viewHierarchy = hint.viewHierarchy; - if (viewHierarchy != null) { + if (viewHierarchy != null && event.type != 'feedback') { attachments.add(viewHierarchy); } @@ -221,6 +222,10 @@ class SentryClient { return event; } + if (event.type == 'feedback') { + return event; + } + if (event.exceptions?.isNotEmpty ?? false) { return event; } @@ -431,6 +436,8 @@ class SentryClient { } /// Reports the [userFeedback] to Sentry.io. + @Deprecated( + 'Will be removed in a future version. Use [captureFeedback] instead') Future captureUserFeedback(SentryUserFeedback userFeedback) { final envelope = SentryEnvelope.fromUserFeedback( userFeedback, @@ -440,6 +447,25 @@ class SentryClient { return _attachClientReportsAndSend(envelope); } + /// Reports the [feedback] to Sentry.io. + Future captureFeedback( + SentryFeedback feedback, { + Scope? scope, + Hint? hint, + }) { + final feedbackEvent = SentryEvent( + type: 'feedback', + contexts: Contexts(feedback: feedback), + level: SentryLevel.info, + ); + + return captureEvent( + feedbackEvent, + scope: scope, + hint: hint, + ); + } + /// Reports the [metricsBuckets] to Sentry.io. Future captureMetrics( Map> metricsBuckets) async { @@ -467,6 +493,7 @@ class SentryClient { final beforeSend = _options.beforeSend; final beforeSendTransaction = _options.beforeSendTransaction; + final beforeSendFeedback = _options.beforeSendFeedback; String beforeSendName = 'beforeSend'; try { @@ -478,6 +505,13 @@ class SentryClient { } else { processedEvent = callbackResult; } + } else if (event.type == 'feedback' && beforeSendFeedback != null) { + final callbackResult = beforeSendFeedback(event, hint); + if (callbackResult is Future) { + processedEvent = await callbackResult; + } else { + processedEvent = callbackResult; + } } else if (beforeSend != null) { final callbackResult = beforeSend(event, hint); if (callbackResult is Future) { diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index fb7cd1543a..9c02bfa8f1 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -1,15 +1,16 @@ import 'dart:convert'; + import 'client_reports/client_report.dart'; import 'metrics/metric.dart'; import 'protocol.dart'; -import 'sentry_item_type.dart'; -import 'sentry_options.dart'; -import 'sentry_trace_context_header.dart'; -import 'utils.dart'; import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_envelope_header.dart'; import 'sentry_envelope_item.dart'; +import 'sentry_item_type.dart'; +import 'sentry_options.dart'; +import 'sentry_trace_context_header.dart'; import 'sentry_user_feedback.dart'; +import 'utils.dart'; /// Class representation of `Envelope` file. class SentryEnvelope { @@ -59,6 +60,7 @@ class SentryEnvelope { ); } + @Deprecated('Will be removed in a future version.') factory SentryEnvelope.fromUserFeedback( SentryUserFeedback feedback, SdkVersion sdkVersion, { diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index 61463cdd8d..c5c3dd962f 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -4,11 +4,11 @@ import 'dart:convert'; import 'client_reports/client_report.dart'; import 'metrics/metric.dart'; import 'protocol.dart'; -import 'utils.dart'; import 'sentry_attachment/sentry_attachment.dart'; -import 'sentry_item_type.dart'; import 'sentry_envelope_item_header.dart'; +import 'sentry_item_type.dart'; import 'sentry_user_feedback.dart'; +import 'utils.dart'; /// Item holding header information and JSON encoded data. class SentryEnvelopeItem { @@ -46,6 +46,7 @@ class SentryEnvelopeItem { } /// Create a [SentryEnvelopeItem] which sends [SentryUserFeedback]. + @Deprecated('Will be removed in a future version.') factory SentryEnvelopeItem.fromUserFeedback(SentryUserFeedback feedback) { final cachedItem = _CachedItem(() async => utf8JsonEncoder.convert(feedback.toJson())); @@ -65,13 +66,14 @@ class SentryEnvelopeItem { _CachedItem(() async => utf8JsonEncoder.convert(event.toJson())); return SentryEnvelopeItem( - SentryEnvelopeItemHeader( - SentryItemType.event, - cachedItem.getDataLength, - contentType: 'application/json', - ), - cachedItem.getData, - originalObject: event); + SentryEnvelopeItemHeader( + event.type == 'feedback' ? 'feedback' : SentryItemType.event, + cachedItem.getDataLength, + contentType: 'application/json', + ), + cachedItem.getData, + originalObject: event, + ); } /// Create a [SentryEnvelopeItem] which holds the [ClientReport] data. diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 83fb887124..1d8cd5bb7d 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -161,6 +161,10 @@ class SentryOptions { /// transaction object or nothing to skip reporting the transaction BeforeSendTransactionCallback? beforeSendTransaction; + /// This function is called with an SDK specific feedback event object and can return a modified + /// feedback event object or nothing to skip reporting the feedback event + BeforeSendCallback? beforeSendFeedback; + /// This function is called with an SDK specific breadcrumb object before the breadcrumb is added /// to the scope. When nothing is returned from the function, the breadcrumb is dropped BeforeBreadcrumbCallback? beforeBreadcrumb; diff --git a/dart/lib/src/sentry_user_feedback.dart b/dart/lib/src/sentry_user_feedback.dart index 055199ed61..722a0983f1 100644 --- a/dart/lib/src/sentry_user_feedback.dart +++ b/dart/lib/src/sentry_user_feedback.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import 'protocol.dart'; import 'protocol/access_aware_map.dart'; +@Deprecated('Will be removed in a future version. Use [SentryFeedback] instead') class SentryUserFeedback { SentryUserFeedback({ required this.eventId, diff --git a/dart/test/hub_test.dart b/dart/test/hub_test.dart index 564c59ff7d..12eaf8d227 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -55,6 +55,24 @@ void main() { }, ); + test( + 'should capture feedback with the default scope', + () async { + final hub = fixture.getSut(); + final feedback = SentryFeedback(message: 'message'); + await hub.captureFeedback(feedback); + + var scope = fixture.client.captureFeedbackCalls.first.scope; + + expect( + fixture.client.captureFeedbackCalls.first.feedback, + feedback, + ); + + expect(scopeEquals(scope, Scope(fixture.options)), true); + }, + ); + test('should capture exception', () async { final hub = fixture.getSut(); await hub.captureException(fakeException); @@ -557,6 +575,23 @@ void main() { expect(fixture.loggedLevel, SentryLevel.error); }); + test('captureFeedback should handle thrown error in scope callback', + () async { + fixture.options.automatedTestMode = false; + final hub = fixture.getSut(debug: true); + final scopeCallbackException = Exception('error in scope callback'); + + ScopeCallback scopeCallback = (Scope scope) { + throw scopeCallbackException; + }; + + final feedback = SentryFeedback(message: 'message'); + await hub.captureFeedback(feedback, withScope: scopeCallback); + + expect(fixture.loggedException, scopeCallbackException); + expect(fixture.loggedLevel, SentryLevel.error); + }); + test('captureException should handle thrown error in scope callback', () async { fixture.options.automatedTestMode = false; @@ -647,6 +682,22 @@ void main() { expect(calls[2].scope?.user, isNull); }); + test('captureFeedback should create a new scope', () async { + final hub = fixture.getSut(); + await hub.captureFeedback(SentryFeedback(message: 'message')); + await hub.captureFeedback(SentryFeedback(message: 'message'), + withScope: (scope) async { + await scope.setUser(SentryUser(id: 'foo bar')); + }); + await hub.captureFeedback(SentryFeedback(message: 'message')); + + var calls = fixture.client.captureFeedbackCalls; + expect(calls.length, 3); + expect(calls[0].scope?.user, isNull); + expect(calls[1].scope?.user?.id, 'foo bar'); + expect(calls[2].scope?.user, isNull); + }); + test('captureException should create a new scope', () async { final hub = fixture.getSut(); await hub.captureException(Exception('0')); diff --git a/dart/test/mocks/mock_hub.dart b/dart/test/mocks/mock_hub.dart index 8fa7d31388..dcd305e821 100644 --- a/dart/test/mocks/mock_hub.dart +++ b/dart/test/mocks/mock_hub.dart @@ -13,6 +13,8 @@ class MockHub with NoSuchMethodProvider implements Hub { List captureMessageCalls = []; List addBreadcrumbCalls = []; List bindClientCalls = []; + + // ignore: deprecated_member_use_from_same_package List userFeedbackCalls = []; List captureTransactionCalls = []; List captureMetricsCalls = []; @@ -133,6 +135,7 @@ class MockHub with NoSuchMethodProvider implements Hub { } @override + // ignore: deprecated_member_use_from_same_package Future captureUserFeedback(SentryUserFeedback userFeedback) async { userFeedbackCalls.add(userFeedback); } diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index 248ea19032..c0e4ba9ffe 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -9,7 +9,10 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureMessageCalls = []; List captureEnvelopeCalls = []; List captureTransactionCalls = []; + + // ignore: deprecated_member_use_from_same_package List userFeedbackCalls = []; + List captureFeedbackCalls = []; List>> captureMetricsCalls = []; int closeCalls = 0; @@ -72,10 +75,25 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { } @override + // ignore: deprecated_member_use_from_same_package Future captureUserFeedback(SentryUserFeedback userFeedback) async { userFeedbackCalls.add(userFeedback); } + @override + Future captureFeedback( + SentryFeedback feedback, { + Scope? scope, + Hint? hint, + }) async { + captureFeedbackCalls.add(CaptureFeedbackCall( + feedback, + scope, + hint, + )); + return SentryId.newId(); + } + @override Future captureMetrics(Map> metrics) async { captureMetricsCalls.add(metrics); @@ -113,6 +131,18 @@ class CaptureEventCall { ); } +class CaptureFeedbackCall { + final SentryFeedback feedback; + final Hint? hint; + final Scope? scope; + + CaptureFeedbackCall( + this.feedback, + this.scope, + this.hint, + ); +} + class CaptureExceptionCall { final dynamic throwable; final dynamic stackTrace; diff --git a/dart/test/mocks/mock_transport.dart b/dart/test/mocks/mock_transport.dart index fad9f43696..55ae027f21 100644 --- a/dart/test/mocks/mock_transport.dart +++ b/dart/test/mocks/mock_transport.dart @@ -17,7 +17,7 @@ class MockTransport implements Transport { } bool called(int calls) { - return calls == calls; + return _calls == calls; } @override diff --git a/dart/test/protocol/contexts_test.dart b/dart/test/protocol/contexts_test.dart index b679c47679..01ad0c6fbd 100644 --- a/dart/test/protocol/contexts_test.dart +++ b/dart/test/protocol/contexts_test.dart @@ -6,6 +6,8 @@ void main() { final _traceId = SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea'); final _spanId = SpanId.fromId('976e0cd945864f60'); final _parentSpanId = SpanId.fromId('c9c9fc3f9d4346df'); + final _associatedEventId = + SentryId.fromId('8a32c0f9be1d34a5efb2c4a10d80de9a'); final _trace = SentryTraceContext( traceId: _traceId, @@ -17,6 +19,15 @@ void main() { status: SpanStatus.ok(), ); + final _feedback = SentryFeedback( + message: 'fixture-message', + contactEmail: 'fixture-contactEmail', + name: 'fixture-name', + replayId: 'fixture-replayId', + url: "https://fixture-url.com", + associatedEventId: _associatedEventId, + ); + final _contexts = Contexts( device: SentryDevice(batteryLevel: 90.0), operatingSystem: SentryOperatingSystem(name: 'name'), @@ -26,6 +37,7 @@ void main() { gpu: SentryGpu(id: 1), culture: SentryCulture(locale: 'foo-bar'), trace: _trace, + feedback: _feedback, ); final _contextsJson = { @@ -44,6 +56,14 @@ void main() { 'description': 'desc', 'status': 'ok' }, + 'feedback': { + 'message': 'fixture-message', + 'contact_email': 'fixture-contactEmail', + 'name': 'fixture-name', + 'replay_id': 'fixture-replayId', + 'url': 'https://fixture-url.com', + 'associated_event_id': '8a32c0f9be1d34a5efb2c4a10d80de9a', + } }; final _contextsMutlipleRuntimes = Contexts( @@ -129,6 +149,14 @@ void main() { description: 'desc', status: SpanStatus.ok(), ); + final feedback = SentryFeedback( + message: 'fixture-2-message', + contactEmail: 'fixture-2-contactEmail', + name: 'fixture-2-name', + replayId: 'fixture-2-replayId', + url: "https://fixture-2-url.com", + associatedEventId: SentryId.fromId('1d49af08b6e2c437f9052b1ecfd83dca'), + ); final copy = data.copyWith( device: device, @@ -138,7 +166,8 @@ void main() { browser: browser, gpu: gpu, culture: culture, - trace: _trace, + trace: trace, + feedback: feedback, ); expect(device.toJson(), copy.device!.toJson()); @@ -153,6 +182,7 @@ void main() { expect(gpu.toJson(), copy.gpu!.toJson()); expect(trace.toJson(), copy.trace!.toJson()); expect('value', copy['extra']); + expect(feedback.toJson(), copy.feedback!.toJson()); }); }); } diff --git a/dart/test/protocol/sentry_feedback_test.dart b/dart/test/protocol/sentry_feedback_test.dart new file mode 100644 index 0000000000..f32fa2f3a8 --- /dev/null +++ b/dart/test/protocol/sentry_feedback_test.dart @@ -0,0 +1,85 @@ +import 'package:collection/collection.dart'; +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + final associatedEventId = SentryId.fromId('8a32c0f9be1d34a5efb2c4a10d80de9a'); + + final feedback = SentryFeedback( + message: 'fixture-message', + contactEmail: 'fixture-contactEmail', + name: 'fixture-name', + replayId: 'fixture-replayId', + url: "https://fixture-url.com", + associatedEventId: associatedEventId, + unknown: testUnknown, + ); + + final feedbackJson = { + 'message': 'fixture-message', + 'contact_email': 'fixture-contactEmail', + 'name': 'fixture-name', + 'replay_id': 'fixture-replayId', + 'url': 'https://fixture-url.com', + 'associated_event_id': '8a32c0f9be1d34a5efb2c4a10d80de9a', + }; + feedbackJson.addAll(testUnknown); + + group('json', () { + test('toJson', () { + final json = feedback.toJson(); + + expect( + MapEquality().equals(feedbackJson, json), + true, + ); + }); + test('fromJson', () { + final feedback = SentryFeedback.fromJson(feedbackJson); + final json = feedback.toJson(); + + print(feedback); + print(json); + + expect( + MapEquality().equals(feedbackJson, json), + true, + ); + }); + }); + + group('copyWith', () { + test('copyWith keeps unchanged', () { + final data = feedback; + + final copy = data.copyWith(); + + expect( + MapEquality().equals(data.toJson(), copy.toJson()), + true, + ); + }); + test('copyWith takes new values', () { + final data = feedback; + + final copy = data.copyWith( + message: 'fixture-2-message', + contactEmail: 'fixture-2-contactEmail', + name: 'fixture-2-name', + replayId: 'fixture-2-replayId', + url: "https://fixture-2-url.com", + associatedEventId: SentryId.fromId('1d49af08b6e2c437f9052b1ecfd83dca'), + ); + + expect(copy.message, 'fixture-2-message'); + expect(copy.contactEmail, 'fixture-2-contactEmail'); + expect(copy.name, 'fixture-2-name'); + expect(copy.replayId, 'fixture-2-replayId'); + expect(copy.url, "https://fixture-2-url.com"); + expect(copy.associatedEventId.toString(), + '1d49af08b6e2c437f9052b1ecfd83dca'); + }); + }); +} diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 843279ed36..7a2b5e52f8 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -13,8 +13,8 @@ import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/transport/data_category.dart'; -import 'package:sentry/src/utils/iterable_utils.dart'; import 'package:sentry/src/transport/spotlight_http_transport.dart'; +import 'package:sentry/src/utils/iterable_utils.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -818,7 +818,7 @@ void main() { scope.setUser(user); }); - test('should apply the scope', () async { + test('should apply the scope to event', () async { final client = fixture.getSut(); await client.captureEvent(event, scope: scope); @@ -842,6 +842,28 @@ void main() { expect( capturedEnvelope.header.traceContext?.replayId, SentryId.fromId('1')); }); + + test('should apply the scope to feedback event', () async { + final client = fixture.getSut(); + final feedback = fixture.fakeFeedback(); + await client.captureFeedback(feedback, scope: scope); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final capturedEvent = await eventFromEnvelope(capturedEnvelope); + + expect(capturedEvent.user?.id, user.id); + expect(capturedEvent.level!.name, SentryLevel.error.name); + expect(capturedEvent.transaction, transaction); + expect(capturedEvent.fingerprint, fingerprint); + expect(capturedEvent.breadcrumbs?.first.toJson(), crumb.toJson()); + expect(capturedEvent.tags, { + scopeTagKey: scopeTagValue, + }); + // ignore: deprecated_member_use_from_same_package + expect(capturedEvent.extra, { + scopeExtraKey: scopeExtraValue, + }); + }); }); group('SentryClient : apply partial scope to the captured event', () { @@ -1017,21 +1039,30 @@ void main() { final client = fixture.getSut(sampleRate: 1.0); await client.captureEvent(fakeEvent); - expect((fixture.transport).called(1), true); + expect(fixture.transport.called(1), true); }); test('do not capture event, sample rate is 0% disabled', () async { final client = fixture.getSut(sampleRate: 0.0); await client.captureEvent(fakeEvent); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('captures event, sample rate is null, disabled', () async { final client = fixture.getSut(); await client.captureEvent(fakeEvent); - expect((fixture.transport).called(1), true); + expect(fixture.transport.called(1), true); + }); + + test('capture feedback event, sample rate is 0% disabled', () async { + final client = fixture.getSut(sampleRate: 0.0); + + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + expect(fixture.transport.called(1), true); }); }); @@ -1050,7 +1081,7 @@ void main() { final client = fixture.getSut(); await client.captureEvent(event); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('drop event if error message partially matches ignoreErrors value', @@ -1060,7 +1091,7 @@ void main() { final client = fixture.getSut(); await client.captureEvent(event); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test( @@ -1071,7 +1102,7 @@ void main() { final client = fixture.getSut(); await client.captureEvent(event); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('send event if error message does not match ignoreErrors value', @@ -1081,7 +1112,7 @@ void main() { final client = fixture.getSut(); await client.captureEvent(event); - expect((fixture.transport).called(1), true); + expect(fixture.transport.called(1), true); }); test('send event if no values are set for ignoreErrors', () async { @@ -1091,7 +1122,7 @@ void main() { final client = fixture.getSut(); await client.captureEvent(event); - expect((fixture.transport).called(1), true); + expect(fixture.transport.called(1), true); }); }); @@ -1113,17 +1144,17 @@ void main() { fakeTransaction.tracer.name = "my-transaction"; await client.captureTransaction(fakeTransaction); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('drop transaction if name partially matches ignoreTransaction value', () async { final client = fixture.getSut(); final fakeTransaction = fixture.fakeTransaction(); - fakeTransaction.tracer.name = "this is a transaction-test"; + fakeTransaction.tracer.name = "this is a my-transaction-test"; await client.captureTransaction(fakeTransaction); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test( @@ -1134,7 +1165,7 @@ void main() { fakeTransaction.tracer.name = "transaction-test message"; await client.captureTransaction(fakeTransaction); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('send transaction if name does not match ignoreTransaction value', @@ -1144,7 +1175,7 @@ void main() { fakeTransaction.tracer.name = "capture"; await client.captureTransaction(fakeTransaction); - expect((fixture.transport).called(1), true); + expect(fixture.transport.called(1), true); }); test('send transaction if no values are set for ignoreTransaction', @@ -1155,7 +1186,7 @@ void main() { fakeTransaction.tracer.name = "this is a test transaction"; await client.captureTransaction(fakeTransaction); - expect((fixture.transport).called(1), true); + expect(fixture.transport.called(1), true); }); }); @@ -1176,7 +1207,7 @@ void main() { final client = fixture.getSut(); await client.captureEvent(event); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('record ignored exceptions dropping event', () async { @@ -1195,6 +1226,62 @@ void main() { }); }); + group('SentryClient before send feedback', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('before send feedback drops event', () async { + final client = fixture.getSut( + beforeSendFeedback: beforeSendFeedbackCallbackDropEvent); + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + expect(fixture.transport.called(0), true); + }); + + test('async before send feedback drops event', () async { + final client = fixture.getSut( + beforeSendFeedback: asyncBeforeSendFeedbackCallbackDropEvent); + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + expect(fixture.transport.called(0), true); + }); + + test( + 'before send feedback returns an feedback event and feedback event is captured', + () async { + final client = + fixture.getSut(beforeSendFeedback: beforeSendFeedbackCallback); + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final feedbackEvent = await eventFromEnvelope(capturedEnvelope); + + expect(feedbackEvent.tags!.containsKey('theme'), true); + }); + + test('thrown error is handled', () async { + fixture.options.automatedTestMode = false; + final exception = Exception("before send exception"); + final beforeSendFeedbackCallback = (SentryEvent event, Hint hint) { + throw exception; + }; + + final client = fixture.getSut( + beforeSendFeedback: beforeSendFeedbackCallback, debug: true); + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + expect(fixture.loggedException, exception); + expect(fixture.loggedLevel, SentryLevel.error); + }); + }); + group('SentryClient before send transaction', () { late Fixture fixture; @@ -1208,7 +1295,7 @@ void main() { final fakeTransaction = fixture.fakeTransaction(); await client.captureTransaction(fakeTransaction); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('async before send transaction drops event', () async { @@ -1217,7 +1304,7 @@ void main() { final fakeTransaction = fixture.fakeTransaction(); await client.captureTransaction(fakeTransaction); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test( @@ -1276,7 +1363,7 @@ void main() { final client = fixture.getSut(beforeSend: beforeSendCallbackDropEvent); await client.captureEvent(fakeEvent); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('async before send drops event', () async { @@ -1284,7 +1371,7 @@ void main() { fixture.getSut(beforeSend: asyncBeforeSendCallbackDropEvent); await client.captureEvent(fakeEvent); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); }); test('before send returns an event and event is captured', () async { @@ -1335,19 +1422,18 @@ void main() { setUp(() { fixture = Fixture(); fixture.options.addEventProcessor(FunctionEventProcessor( - (event, hint) => event - ..tags!.addAll({'theme': 'material'}) + (event, hint) => event.copyWith(tags: {'theme': 'material'}) // ignore: deprecated_member_use_from_same_package - ..extra!['host'] = '0.0.0.1' - ..modules!.addAll({'core': '1.0'}) - ..breadcrumbs!.add(Breadcrumb(message: 'processor crumb')) - ..fingerprint!.add('process') - ..sdk!.addIntegration('testIntegration') - ..sdk!.addPackage('test-pkg', '1.0'), + ..extra?['host'] = '0.0.0.1' + ..modules?.addAll({'core': '1.0'}) + ..breadcrumbs?.add(Breadcrumb(message: 'processor crumb')) + ..fingerprint?.add('process') + ..sdk?.addIntegration('testIntegration') + ..sdk?.addPackage('test-pkg', '1.0'), )); }); - test('should execute eventProcessors', () async { + test('should execute eventProcessors for event', () async { final client = fixture.getSut(); await client.captureEvent(fakeEvent); @@ -1371,7 +1457,18 @@ void main() { expect(event.fingerprint!.contains('process'), true); }); - test('should pass hint to eventProcessors', () async { + test('should execute eventProcessors for feedback', () async { + final client = fixture.getSut(); + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final event = await eventFromEnvelope(capturedEnvelope); + + expect(event.tags?.containsKey('theme'), true); + }); + + test('should pass hint to eventProcessors for event', () async { final myHint = Hint(); myHint.set('string', 'hint'); @@ -1389,7 +1486,26 @@ void main() { expect(executed, true); }); - test('should create hint when none was provided', () async { + test('should pass hint to eventProcessors for feedback', () async { + final myHint = Hint(); + myHint.set('string', 'hint'); + + var executed = false; + + final client = + fixture.getSut(eventProcessor: FunctionEventProcessor((event, hint) { + expect(myHint, hint); + executed = true; + return event; + })); + + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback, hint: myHint); + + expect(executed, true); + }); + + test('should create hint when none was provided for event', () async { var executed = false; final client = @@ -1404,11 +1520,66 @@ void main() { expect(executed, true); }); + test('should create hint when none was provided for feedback event', + () async { + var executed = false; + + final client = + fixture.getSut(eventProcessor: FunctionEventProcessor((event, hint) { + expect(hint, isNotNull); + executed = true; + return event; + })); + + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + expect(executed, true); + }); + test('event processor drops the event', () async { final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + await client.captureEvent(fakeEvent); - expect((fixture.transport).called(0), true); + expect(fixture.transport.called(0), true); + }); + + test('event processor drops the feedback event', () async { + final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback); + + expect(fixture.transport.called(0), true); + }); + }); + + group('SentryClient captures feedback', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('should capture feedback as event', () async { + final client = fixture.getSut(); + + final feedback = fixture.fakeFeedback(); + await client.captureFeedback(feedback); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final envelopeItem = capturedEnvelope.items.first; + final envelopeEvent = envelopeItem.originalObject as SentryEvent?; + + expect(envelopeItem, isNotNull); + expect(envelopeEvent, isNotNull); + + expect(envelopeItem.header.type, 'feedback'); + + expect(envelopeEvent?.type, 'feedback'); + expect(envelopeEvent?.contexts.feedback?.toJson(), feedback.toJson()); + expect(envelopeEvent?.level, SentryLevel.info); }); }); @@ -1570,12 +1741,14 @@ void main() { final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); final id = SentryId.newId(); + // ignore: deprecated_member_use_from_same_package final feedback = SentryUserFeedback( eventId: id, comments: 'this is awesome', email: 'sentry@example.com', name: 'Rockstar Developer', ); + // ignore: deprecated_member_use_from_same_package await client.captureUserFeedback(feedback); expect(fixture.recorder.flushCalled, true); @@ -1591,12 +1764,14 @@ void main() { final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); final id = SentryId.newId(); + // ignore: deprecated_member_use_from_same_package final feedback = SentryUserFeedback( eventId: id, comments: 'this is awesome', email: 'sentry@example.com', name: 'Rockstar Developer', ); + // ignore: deprecated_member_use_from_same_package await client.captureUserFeedback(feedback); final envelope = fixture.transport.envelopes.first; @@ -1766,10 +1941,12 @@ void main() { test('user feedback envelope contains dsn', () async { final client = fixture.getSut(); final event = SentryEvent(); + // ignore: deprecated_member_use_from_same_package final feedback = SentryUserFeedback( eventId: event.eventId, name: 'test', ); + // ignore: deprecated_member_use_from_same_package await client.captureUserFeedback(feedback); final capturedEnvelope = (fixture.transport).envelopes.first; @@ -1846,6 +2023,158 @@ void main() { }); }); + group('trace context', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('captureEvent adds trace context', () async { + final client = fixture.getSut(); + + final scope = Scope(fixture.options); + scope.span = + SentrySpan(fixture.tracer, fixture.tracer.context, MockHub()); + + await client.captureEvent(fakeEvent, scope: scope); + + final envelope = fixture.transport.envelopes.first; + expect(envelope.header.traceContext, isNotNull); + }); + + test('captureTransaction adds trace context', () async { + final client = fixture.getSut(); + + final tr = SentryTransaction(fixture.tracer); + + final context = SentryTraceContextHeader.fromJson({ + 'trace_id': '${tr.eventId}', + 'public_key': '123', + }); + + await client.captureTransaction(tr, traceContext: context); + + final envelope = fixture.transport.envelopes.first; + expect(envelope.header.traceContext, isNotNull); + }); + + test('captureFeedback adds trace context', () async { + final client = fixture.getSut(); + + final scope = Scope(fixture.options); + scope.span = + SentrySpan(fixture.tracer, fixture.tracer.context, MockHub()); + + await client.captureFeedback(fixture.fakeFeedback(), scope: scope); + + final envelope = fixture.transport.envelopes.first; + expect(envelope.header.traceContext, isNotNull); + }); + }); + + group('Hint', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('captureEvent adds attachments from hint', () async { + final attachment = SentryAttachment.fromIntList([], "fixture-fileName"); + final hint = Hint.withAttachment(attachment); + + final sut = fixture.getSut(); + await sut.captureEvent(fakeEvent, hint: hint); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final attachmentItem = IterableUtils.firstWhereOrNull( + capturedEnvelope.items, + (SentryEnvelopeItem e) => e.header.type == SentryItemType.attachment, + ); + expect(attachmentItem?.header.attachmentType, + SentryAttachment.typeAttachmentDefault); + }); + + test('captureFeedback adds attachments from hint', () async { + final attachment = SentryAttachment.fromIntList([], "fixture-fileName"); + final hint = Hint.withAttachment(attachment); + + final sut = fixture.getSut(); + final fakeFeedback = fixture.fakeFeedback(); + await sut.captureFeedback(fakeFeedback, hint: hint); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final attachmentItem = IterableUtils.firstWhereOrNull( + capturedEnvelope.items, + (SentryEnvelopeItem e) => e.header.type == SentryItemType.attachment, + ); + expect(attachmentItem?.header.attachmentType, + SentryAttachment.typeAttachmentDefault); + }); + + test('captureEvent adds screenshot from hint', () async { + final client = fixture.getSut(); + final screenshot = + SentryAttachment.fromScreenshotData(Uint8List.fromList([0, 0, 0, 0])); + final hint = Hint.withScreenshot(screenshot); + + 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.fileName, 'screenshot.png'); + }); + + test('captureFeedback adds screenshot from hint', () async { + final client = fixture.getSut(); + final screenshot = + SentryAttachment.fromScreenshotData(Uint8List.fromList([0, 0, 0, 0])); + final hint = Hint.withScreenshot(screenshot); + + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback, hint: hint); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final attachmentItem = capturedEnvelope.items.firstWhereOrNull( + (element) => element.header.type == SentryItemType.attachment); + 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('captureFeedback does not add viewHierarchy from hint', () async { + final client = fixture.getSut(); + final view = SentryViewHierarchy('flutter'); + final attachment = SentryAttachment.fromViewHierarchy(view); + final hint = Hint.withViewHierarchy(attachment); + + final fakeFeedback = fixture.fakeFeedback(); + await client.captureFeedback(fakeFeedback, hint: hint); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final attachmentItem = capturedEnvelope.items.firstWhereOrNull( + (element) => element.header.type == SentryItemType.attachment); + + expect(attachmentItem, isNull); + }); + }); + group('Capture metrics', () { late Fixture fixture; @@ -1908,6 +2237,20 @@ SentryEvent? beforeSendCallbackDropEvent( ) => null; +SentryTransaction? beforeSendFeedbackCallbackDropEvent( + SentryEvent feedbackEvent, + Hint hint, +) => + null; + +Future asyncBeforeSendFeedbackCallbackDropEvent( + SentryEvent feedbackEvent, + Hint hint, +) async { + await Future.delayed(Duration(milliseconds: 200)); + return null; +} + SentryTransaction? beforeSendTransactionCallbackDropEvent( SentryTransaction event, ) => @@ -1927,6 +2270,10 @@ Future asyncBeforeSendTransactionCallbackDropEvent( return null; } +SentryEvent? beforeSendFeedbackCallback(SentryEvent event, Hint hint) { + return event.copyWith(tags: {'theme': 'material'}); +} + SentryEvent? beforeSendCallback(SentryEvent event, Hint hint) { return event ..tags!.addAll({'theme': 'material'}) @@ -1971,6 +2318,7 @@ class Fixture { double? sampleRate, BeforeSendCallback? beforeSend, BeforeSendTransactionCallback? beforeSendTransaction, + BeforeSendCallback? beforeSendFeedback, EventProcessor? eventProcessor, bool provideMockRecorder = true, bool debug = false, @@ -1990,6 +2338,7 @@ class Fixture { options.sampleRate = sampleRate; options.beforeSend = beforeSend; options.beforeSendTransaction = beforeSendTransaction; + options.beforeSendFeedback = beforeSendFeedback; options.debug = debug; options.logger = mockLogger; @@ -2017,6 +2366,25 @@ class Fixture { ); } + SentryEvent fakeFeedbackEvent() { + return SentryEvent( + type: 'feedback', + contexts: Contexts(feedback: fakeFeedback()), + level: SentryLevel.info, + ); + } + + SentryFeedback fakeFeedback() { + return SentryFeedback( + message: 'fixture-message', + contactEmail: 'fixture-contactEmail', + name: 'fixture-name', + replayId: 'fixture-replayId', + url: "https://fixture-url.com", + associatedEventId: SentryId.fromId('1d49af08b6e2c437f9052b1ecfd83dca'), + ); + } + void mockLogger( SentryLevel level, String message, { diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index c5a205c945..9074ee712c 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -59,6 +59,32 @@ void main() { expect(actualData, expectedData); }); + test('fromEvent feedback', () async { + final feedback = SentryFeedback( + message: 'fixture-message', + ); + final feedbackEvent = SentryEvent( + type: 'feedback', + contexts: Contexts(feedback: feedback), + level: SentryLevel.info, + ); + final sut = SentryEnvelopeItem.fromEvent(feedbackEvent); + + final expectedData = utf8.encode(jsonEncode( + feedbackEvent.toJson(), + toEncodable: jsonSerializationFallback, + )); + final actualData = await sut.dataFactory(); + + final expectedLength = expectedData.length; + final actualLength = await sut.header.length(); + + expect(sut.header.contentType, 'application/json'); + expect(sut.header.type, 'feedback'); + expect(actualLength, expectedLength); + expect(actualData, expectedData); + }); + test('fromTransaction', () async { final context = SentryTransactionContext( 'name', @@ -113,11 +139,13 @@ void main() { }); test('fromUserFeedback', () async { + // ignore: deprecated_member_use_from_same_package final userFeedback = SentryUserFeedback( eventId: SentryId.newId(), name: 'name', comments: 'comments', email: 'email'); + // ignore: deprecated_member_use_from_same_package final sut = SentryEnvelopeItem.fromUserFeedback(userFeedback); final expectedData = utf8.encode(jsonEncode( diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index 7fe59d099d..bf30b853c4 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -135,10 +135,12 @@ void main() { test('fromUserFeedback', () async { final eventId = SentryId.newId(); + // ignore: deprecated_member_use_from_same_package final userFeedback = SentryUserFeedback( eventId: eventId, name: 'name', email: 'email', comments: 'comments'); final sdkVersion = SdkVersion(name: 'fixture-name', version: 'fixture-version'); + // ignore: deprecated_member_use_from_same_package final sut = SentryEnvelope.fromUserFeedback( userFeedback, sdkVersion, @@ -146,6 +148,7 @@ void main() { ); final expectedEnvelopeItem = + // ignore: deprecated_member_use_from_same_package SentryEnvelopeItem.fromUserFeedback(userFeedback); expect(sut.header.eventId, eventId); diff --git a/dart/test/sentry_test.dart b/dart/test/sentry_test.dart index 69b89eae37..5658569829 100644 --- a/dart/test/sentry_test.dart +++ b/dart/test/sentry_test.dart @@ -46,6 +46,15 @@ void main() { expect(client.captureEventCalls.first.scope, isNotNull); }); + test('should capture the feedback event', () async { + final fakeFeedback = SentryFeedback(message: 'message'); + await Sentry.captureFeedback(fakeFeedback); + + expect(client.captureFeedbackCalls.length, 1); + expect(client.captureFeedbackCalls.first.feedback, fakeFeedback); + expect(client.captureFeedbackCalls.first.scope, isNotNull); + }); + test('should capture the event withScope', () async { await Sentry.captureEvent( fakeEvent, @@ -59,6 +68,19 @@ void main() { expect(client.captureEventCalls.first.scope?.user?.id, 'foo bar'); }); + test('should capture the feedback event withScope', () async { + final fakeFeedback = SentryFeedback(message: 'message'); + await Sentry.captureFeedback( + fakeFeedback, + withScope: (scope) { + scope.setUser(SentryUser(id: 'foo bar')); + }, + ); + + expect(client.captureFeedbackCalls.length, 1); + expect(client.captureFeedbackCalls.first.scope?.user?.id, 'foo bar'); + }); + test('should not capture a null exception', () async { await Sentry.captureException(null); expect(client.captureEventCalls.length, 0); diff --git a/dart/test/sentry_user_feedback_test.dart b/dart/test/sentry_user_feedback_test.dart index fec01ca5bd..c724f6d8c1 100644 --- a/dart/test/sentry_user_feedback_test.dart +++ b/dart/test/sentry_user_feedback_test.dart @@ -8,9 +8,11 @@ import 'mocks/mock_transport.dart'; import 'test_utils.dart'; void main() { + // ignore: deprecated_member_use_from_same_package group('$SentryUserFeedback', () { final id = SentryId.newId(); + // ignore: deprecated_member_use_from_same_package final feedback = SentryUserFeedback( eventId: id, comments: 'this is awesome', @@ -46,6 +48,7 @@ void main() { test('copyWith', () { final id = SentryId.newId(); + // ignore: deprecated_member_use_from_same_package final feedback = SentryUserFeedback( eventId: id, comments: 'this is awesome', @@ -69,17 +72,21 @@ void main() { test('disallow empty id', () { final id = SentryId.empty(); + // ignore: deprecated_member_use_from_same_package expect(() => SentryUserFeedback(eventId: id), throwsA(isA())); }); }); + // ignore: deprecated_member_use_from_same_package group('$SentryUserFeedback to envelops', () { test('to envelope', () { + // ignore: deprecated_member_use_from_same_package final feedback = SentryUserFeedback( eventId: SentryId.newId(), name: 'test', ); + // ignore: deprecated_member_use_from_same_package final envelope = SentryEnvelope.fromUserFeedback( feedback, SdkVersion(name: 'a', version: 'b'), @@ -96,9 +103,11 @@ void main() { }); }); + // ignore: deprecated_member_use_from_same_package test('sending $SentryUserFeedback', () async { final fixture = Fixture(); final sut = fixture.getSut(); + // ignore: deprecated_member_use_from_same_package await sut.captureUserFeedback(SentryUserFeedback( eventId: SentryId.newId(), name: 'test', @@ -107,18 +116,23 @@ void main() { expect(fixture.transport.envelopes.length, 1); }); + // ignore: deprecated_member_use_from_same_package test('cannot create $SentryUserFeedback with empty id', () async { expect( + // ignore: deprecated_member_use_from_same_package () => SentryUserFeedback(eventId: const SentryId.empty()), throwsA(isA()), ); }); + // ignore: deprecated_member_use_from_same_package test('do not send $SentryUserFeedback when disabled', () async { final fixture = Fixture(); final sut = fixture.getSut(); await sut.close(); + // ignore: deprecated_member_use_from_same_package await sut.captureUserFeedback( + // ignore: deprecated_member_use_from_same_package SentryUserFeedback( eventId: SentryId.newId(), name: 'test', @@ -128,10 +142,12 @@ void main() { expect(fixture.transport.envelopes.length, 0); }); + // ignore: deprecated_member_use_from_same_package test('do not send $SentryUserFeedback with empty id', () async { final fixture = Fixture(); final sut = fixture.getSut(); await sut.close(); + // ignore: deprecated_member_use_from_same_package await sut.captureUserFeedback( SentryUserFeedbackWithoutAssert( eventId: SentryId.empty(), @@ -148,7 +164,9 @@ void main() { final sut = Hub(options); await expectLater(() async { + // ignore: deprecated_member_use_from_same_package await sut.captureUserFeedback( + // ignore: deprecated_member_use_from_same_package SentryUserFeedback(eventId: SentryId.newId(), name: 'name'), ); }, returnsNormally); @@ -169,6 +187,7 @@ class Fixture { // You cannot create an instance of SentryUserFeedback with an empty id. // In order to test that UserFeedback with an empty id is not sent // we need to implement it and remove the assert. +// ignore: deprecated_member_use_from_same_package class SentryUserFeedbackWithoutAssert implements SentryUserFeedback { SentryUserFeedbackWithoutAssert({ required this.eventId, @@ -205,12 +224,14 @@ class SentryUserFeedbackWithoutAssert implements SentryUserFeedback { } @override + // ignore: deprecated_member_use_from_same_package SentryUserFeedback copyWith({ SentryId? eventId, String? name, String? email, String? comments, }) { + // ignore: deprecated_member_use_from_same_package return SentryUserFeedback( eventId: eventId ?? this.eventId, name: name ?? this.name, diff --git a/dio/test/mocks/mock_hub.dart b/dio/test/mocks/mock_hub.dart index 5e896d58d7..d798b551ac 100644 --- a/dio/test/mocks/mock_hub.dart +++ b/dio/test/mocks/mock_hub.dart @@ -1,5 +1,4 @@ import 'package:meta/meta.dart'; - import 'package:sentry/sentry.dart'; import '../mocks.dart'; @@ -11,6 +10,8 @@ class MockHub with NoSuchMethodProvider implements Hub { List captureMessageCalls = []; List addBreadcrumbCalls = []; List bindClientCalls = []; + + // ignore: deprecated_member_use List userFeedbackCalls = []; List captureTransactionCalls = []; int closeCalls = 0; @@ -122,6 +123,7 @@ class MockHub with NoSuchMethodProvider implements Hub { } @override + // ignore: deprecated_member_use Future captureUserFeedback(SentryUserFeedback userFeedback) async { userFeedbackCalls.add(userFeedback); } diff --git a/flutter/example/integration_test/integration_test.dart b/flutter/example/integration_test/integration_test.dart index 89ee5723f4..bcfd55eb72 100644 --- a/flutter/example/integration_test/integration_test.dart +++ b/flutter/example/integration_test/integration_test.dart @@ -82,14 +82,32 @@ void main() { testWidgets('setup sentry and capture user feedback', (tester) async { await setupSentryAndApp(tester); + // ignore: deprecated_member_use_from_same_package + // ignore: deprecated_member_use final feedback = SentryUserFeedback( eventId: SentryId.newId(), name: 'fixture-name', email: 'fixture@email.com', comments: 'fixture-comments'); + // ignore: deprecated_member_use await Sentry.captureUserFeedback(feedback); }); + testWidgets('setup sentry and capture feedback', (tester) async { + await setupSentryAndApp(tester); + + // ignore: deprecated_member_use_from_same_package + // ignore: deprecated_member_use + final associatedEventId = await Sentry.captureMessage("Associated"); + final feedback = SentryFeedback( + message: 'message', + contactEmail: 'john.appleseed@apple.com', + name: 'John Appleseed', + associatedEventId: associatedEventId, + ); + await Sentry.captureFeedback(feedback); + }); + testWidgets('setup sentry and close', (tester) async { await setupSentryAndApp(tester); diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index f6b879b294..cc551e2bef 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -28,7 +28,6 @@ import 'auto_close_screen.dart'; import 'drift/connection/connection.dart'; import 'drift/database.dart'; import 'isar/user.dart'; -import 'user_feedback_dialog.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String exampleDsn = @@ -453,7 +452,7 @@ class MainScaffold extends StatelessWidget { Sentry.captureMessage( 'This message has an attachment', withScope: (scope) { - const txt = 'Lorem Ipsum dolar sit amet'; + const txt = 'Lorem Ipsum dolor sit amet'; scope.addAttachment( SentryAttachment.fromIntList( utf8.encode(txt), @@ -501,28 +500,18 @@ class MainScaffold extends StatelessWidget { onPressed: () async { final id = await Sentry.captureMessage('UserFeedback'); if (!context.mounted) return; - await showDialog( - context: context, - builder: (context) { - return UserFeedbackDialog(eventId: id); - }, + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SentryFeedbackWidget(associatedEventId: id), + fullscreenDialog: true, + ), ); }, text: - 'Shows a custom user feedback dialog without an ongoing event that captures and sends user feedback data to Sentry.', - buttonTitle: 'Capture User Feedback', - ), - TooltipButton( - onPressed: () async { - await showDialog( - context: context, - builder: (context) { - return UserFeedbackDialog(eventId: SentryId.newId()); - }, - ); - }, - text: '', - buttonTitle: 'Show UserFeedback Dialog without event', + 'Shows a custom feedback dialog without an ongoing event that captures and sends user feedback data to Sentry.', + buttonTitle: 'Capture Feedback', ), TooltipButton( onPressed: () { diff --git a/flutter/example/lib/user_feedback_dialog.dart b/flutter/example/lib/user_feedback_dialog.dart deleted file mode 100644 index 159f3c69d9..0000000000 --- a/flutter/example/lib/user_feedback_dialog.dart +++ /dev/null @@ -1,462 +0,0 @@ -// ignore_for_file: library_private_types_in_public_api - -import 'package:flutter/material.dart'; - -import 'package:sentry_flutter/sentry_flutter.dart'; - -class UserFeedbackDialog extends StatefulWidget { - const UserFeedbackDialog({ - super.key, - required this.eventId, - this.hub, - }) : assert(eventId != const SentryId.empty()); - - final SentryId eventId; - final Hub? hub; - - @override - _UserFeedbackDialogState createState() => _UserFeedbackDialogState(); -} - -class _UserFeedbackDialogState extends State { - TextEditingController nameController = TextEditingController(); - TextEditingController emailController = TextEditingController(); - TextEditingController commentController = TextEditingController(); - - @override - Widget build(BuildContext context) { - return AlertDialog( - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "It looks like we're having some internal issues.", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 4), - Text( - 'Our team has been notified. ' - "If you'd like to help, tell us what happened below.", - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.grey), - ), - const Divider(height: 24), - TextField( - key: const ValueKey('sentry_name_textfield'), - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'Name', - ), - controller: nameController, - keyboardType: TextInputType.text, - ), - const SizedBox(height: 8), - TextField( - key: const ValueKey('sentry_email_textfield'), - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'E-Mail', - ), - controller: emailController, - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 8), - TextField( - key: const ValueKey('sentry_comment_textfield'), - minLines: 5, - maxLines: null, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'What happened?', - ), - controller: commentController, - keyboardType: TextInputType.multiline, - ), - const SizedBox(height: 8), - const _PoweredBySentryMessage(), - ], - ), - ), - actions: [ - ElevatedButton( - key: const ValueKey('sentry_submit_feedback_button'), - onPressed: () async { - final feedback = SentryUserFeedback( - eventId: widget.eventId, - comments: commentController.text, - email: emailController.text, - name: nameController.text, - ); - await _submitUserFeedback(feedback); - // ignore: use_build_context_synchronously - Navigator.pop(context); - }, - child: const Text('Submit Crash Report')), - TextButton( - key: const ValueKey('sentry_close_button'), - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Close'), - ) - ], - ); - } - - Future _submitUserFeedback(SentryUserFeedback feedback) { - return (widget.hub ?? HubAdapter()).captureUserFeedback(feedback); - } -} - -class _PoweredBySentryMessage extends StatelessWidget { - const _PoweredBySentryMessage(); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Crash reports powered by'), - const SizedBox(width: 8), - SizedBox( - height: 30, - child: _SentryLogo(), - ), - ], - ), - ); - } -} - -class _SentryLogo extends StatelessWidget { - @override - Widget build(BuildContext context) { - var color = Colors.white; - final brightenss = Theme.of(context).brightness; - if (brightenss == Brightness.light) { - color = const Color(0xff362d59); - } - - return FittedBox( - fit: BoxFit.contain, - child: CustomPaint( - size: const Size(222, 66), - painter: _SentryLogoCustomPainter(color), - ), - ); - } -} - -/// Created with https://fluttershapemaker.com/ -/// Sentry Logo comes from https://sentry.io/branding/ -class _SentryLogoCustomPainter extends CustomPainter { - final Color color; - - _SentryLogoCustomPainter(this.color); - - @override - void paint(Canvas canvas, Size size) { - final path_0 = Path(); - path_0.moveTo(size.width * 0.1306306, size.height * 0.03424242); - path_0.arcToPoint(Offset(size.width * 0.09459459, size.height * 0.03424242), - radius: Radius.elliptical( - size.width * 0.02103604, size.height * 0.07075758), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.06495495, size.height * 0.2050000); - path_0.arcToPoint(Offset(size.width * 0.1449099, size.height * 0.6089394), - radius: - Radius.elliptical(size.width * 0.1450901, size.height * 0.4880303), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.1240991, size.height * 0.6089394); - path_0.arcToPoint(Offset(size.width * 0.05445946, size.height * 0.2646970), - radius: - Radius.elliptical(size.width * 0.1246847, size.height * 0.4193939), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.02702703, size.height * 0.4242424); - path_0.arcToPoint(Offset(size.width * 0.06860360, size.height * 0.6086364), - radius: - Radius.elliptical(size.width * 0.07171171, size.height * 0.2412121), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.02081081, size.height * 0.6086364); - path_0.arcToPoint(Offset(size.width * 0.01801802, size.height * 0.5918182), - radius: Radius.elliptical( - size.width * 0.003423423, size.height * 0.01151515), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.03126126, size.height * 0.5160606); - path_0.arcToPoint(Offset(size.width * 0.01612613, size.height * 0.4872727), - radius: - Radius.elliptical(size.width * 0.04837838, size.height * 0.1627273), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.003018018, size.height * 0.5630303); - path_0.arcToPoint(Offset(size.width * 0.01063063, size.height * 0.6575758), - radius: Radius.elliptical( - size.width * 0.02045045, size.height * 0.06878788), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.arcToPoint(Offset(size.width * 0.02081081, size.height * 0.6666667), - radius: Radius.elliptical( - size.width * 0.02099099, size.height * 0.07060606), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.08626126, size.height * 0.6666667); - path_0.arcToPoint(Offset(size.width * 0.05022523, size.height * 0.4043939), - radius: - Radius.elliptical(size.width * 0.08738739, size.height * 0.2939394), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.06063063, size.height * 0.3437879); - path_0.arcToPoint(Offset(size.width * 0.1070270, size.height * 0.6666667), - radius: - Radius.elliptical(size.width * 0.1075225, size.height * 0.3616667), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.1624775, size.height * 0.6666667); - path_0.arcToPoint(Offset(size.width * 0.08855856, size.height * 0.1848485), - radius: - Radius.elliptical(size.width * 0.1616216, size.height * 0.5436364), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.lineTo(size.width * 0.1095946, size.height * 0.06363636); - path_0.arcToPoint(Offset(size.width * 0.1143243, size.height * 0.05954545), - radius: Radius.elliptical( - size.width * 0.003468468, size.height * 0.01166667), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.cubicTo( - size.width * 0.1167117, - size.height * 0.06393939, - size.width * 0.2057207, - size.height * 0.5863636, - size.width * 0.2073874, - size.height * 0.5924242); - path_0.arcToPoint(Offset(size.width * 0.2043243, size.height * 0.6095455), - radius: Radius.elliptical( - size.width * 0.003423423, size.height * 0.01151515), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.1828829, size.height * 0.6095455); - path_0.quadraticBezierTo(size.width * 0.1832883, size.height * 0.6384848, - size.width * 0.1828829, size.height * 0.6672727); - path_0.lineTo(size.width * 0.2044144, size.height * 0.6672727); - path_0.arcToPoint(Offset(size.width * 0.2252252, size.height * 0.5974242), - radius: Radius.elliptical( - size.width * 0.02067568, size.height * 0.06954545), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.arcToPoint(Offset(size.width * 0.2224324, size.height * 0.5628788), - radius: Radius.elliptical( - size.width * 0.02022523, size.height * 0.06803030), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.close(); - path_0.moveTo(size.width * 0.5600000, size.height * 0.4284848); - path_0.lineTo(size.width * 0.4935135, size.height * 0.1396970); - path_0.lineTo(size.width * 0.4769369, size.height * 0.1396970); - path_0.lineTo(size.width * 0.4769369, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4937387, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4937387, size.height * 0.2301515); - path_0.lineTo(size.width * 0.5621171, size.height * 0.5268182); - path_0.lineTo(size.width * 0.5768018, size.height * 0.5268182); - path_0.lineTo(size.width * 0.5768018, size.height * 0.1396970); - path_0.lineTo(size.width * 0.5600000, size.height * 0.1396970); - path_0.close(); - path_0.moveTo(size.width * 0.3925676, size.height * 0.3566667); - path_0.lineTo(size.width * 0.4521622, size.height * 0.3566667); - path_0.lineTo(size.width * 0.4521622, size.height * 0.3063636); - path_0.lineTo(size.width * 0.3925225, size.height * 0.3063636); - path_0.lineTo(size.width * 0.3925225, size.height * 0.1898485); - path_0.lineTo(size.width * 0.4597748, size.height * 0.1898485); - path_0.lineTo(size.width * 0.4597748, size.height * 0.1395455); - path_0.lineTo(size.width * 0.3754054, size.height * 0.1395455); - path_0.lineTo(size.width * 0.3754054, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4606306, size.height * 0.5268182); - path_0.lineTo(size.width * 0.4606306, size.height * 0.4765152); - path_0.lineTo(size.width * 0.3925225, size.height * 0.4765152); - path_0.close(); - path_0.moveTo(size.width * 0.3224775, size.height * 0.3075758); - path_0.lineTo(size.width * 0.3224775, size.height * 0.3075758); - path_0.cubicTo( - size.width * 0.2992793, - size.height * 0.2887879, - size.width * 0.2927928, - size.height * 0.2739394, - size.width * 0.2927928, - size.height * 0.2378788); - path_0.cubicTo( - size.width * 0.2927928, - size.height * 0.2054545, - size.width * 0.3013063, - size.height * 0.1834848, - size.width * 0.3140090, - size.height * 0.1834848); - path_0.arcToPoint(Offset(size.width * 0.3458559, size.height * 0.2221212), - radius: - Radius.elliptical(size.width * 0.05432432, size.height * 0.1827273), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.3548649, size.height * 0.1792424); - path_0.arcToPoint(Offset(size.width * 0.3143243, size.height * 0.1337879), - radius: - Radius.elliptical(size.width * 0.06351351, size.height * 0.2136364), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.cubicTo( - size.width * 0.2915315, - size.height * 0.1337879, - size.width * 0.2756306, - size.height * 0.1792424, - size.width * 0.2756306, - size.height * 0.2439394); - path_0.cubicTo( - size.width * 0.2756306, - size.height * 0.3136364, - size.width * 0.2891441, - size.height * 0.3377273, - size.width * 0.3137387, - size.height * 0.3578788); - path_0.cubicTo( - size.width * 0.3356306, - size.height * 0.3748485, - size.width * 0.3423423, - size.height * 0.3906061, - size.width * 0.3423423, - size.height * 0.4259091); - path_0.cubicTo( - size.width * 0.3423423, - size.height * 0.4612121, - size.width * 0.3333333, - size.height * 0.4830303, - size.width * 0.3194144, - size.height * 0.4830303); - path_0.arcToPoint(Offset(size.width * 0.2820270, size.height * 0.4336364), - radius: - Radius.elliptical(size.width * 0.05558559, size.height * 0.1869697), - rotation: 0, - largeArc: false, - clockwise: true); - path_0.lineTo(size.width * 0.2718919, size.height * 0.4743939); - path_0.arcToPoint(Offset(size.width * 0.3188288, size.height * 0.5327273), - radius: - Radius.elliptical(size.width * 0.07180180, size.height * 0.2415152), - rotation: 0, - largeArc: false, - clockwise: false); - path_0.cubicTo( - size.width * 0.3435135, - size.height * 0.5327273, - size.width * 0.3593694, - size.height * 0.4880303, - size.width * 0.3593694, - size.height * 0.4189394); - path_0.cubicTo( - size.width * 0.3592342, - size.height * 0.3604545, - size.width * 0.3489640, - size.height * 0.3290909, - size.width * 0.3224775, - size.height * 0.3075758); - path_0.close(); - path_0.moveTo(size.width * 0.8815315, size.height * 0.1396970); - path_0.lineTo(size.width * 0.8468919, size.height * 0.3215152); - path_0.lineTo(size.width * 0.8124775, size.height * 0.1396970); - path_0.lineTo(size.width * 0.7923874, size.height * 0.1396970); - path_0.lineTo(size.width * 0.8378378, size.height * 0.3737879); - path_0.lineTo(size.width * 0.8378378, size.height * 0.5269697); - path_0.lineTo(size.width * 0.8551351, size.height * 0.5269697); - path_0.lineTo(size.width * 0.8551351, size.height * 0.3719697); - path_0.lineTo(size.width * 0.9009009, size.height * 0.1396970); - path_0.close(); - path_0.moveTo(size.width * 0.5904054, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6281081, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6281081, size.height * 0.5269697); - path_0.lineTo(size.width * 0.6454054, size.height * 0.5269697); - path_0.lineTo(size.width * 0.6454054, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6831081, size.height * 0.1921212); - path_0.lineTo(size.width * 0.6831081, size.height * 0.1396970); - path_0.lineTo(size.width * 0.5904505, size.height * 0.1396970); - path_0.close(); - path_0.moveTo(size.width * 0.7631081, size.height * 0.3757576); - path_0.cubicTo( - size.width * 0.7804955, - size.height * 0.3595455, - size.width * 0.7901351, - size.height * 0.3186364, - size.width * 0.7901351, - size.height * 0.2601515); - path_0.cubicTo( - size.width * 0.7901351, - size.height * 0.1857576, - size.width * 0.7739640, - size.height * 0.1389394, - size.width * 0.7478829, - size.height * 0.1389394); - path_0.lineTo(size.width * 0.6967117, size.height * 0.1389394); - path_0.lineTo(size.width * 0.6967117, size.height * 0.5266667); - path_0.lineTo(size.width * 0.7138288, size.height * 0.5266667); - path_0.lineTo(size.width * 0.7138288, size.height * 0.3875758); - path_0.lineTo(size.width * 0.7428829, size.height * 0.3875758); - path_0.lineTo(size.width * 0.7720721, size.height * 0.5269697); - path_0.lineTo(size.width * 0.7920721, size.height * 0.5269697); - path_0.lineTo(size.width * 0.7605405, size.height * 0.3781818); - path_0.close(); - path_0.moveTo(size.width * 0.7137838, size.height * 0.3378788); - path_0.lineTo(size.width * 0.7137838, size.height * 0.1909091); - path_0.lineTo(size.width * 0.7460811, size.height * 0.1909091); - path_0.cubicTo( - size.width * 0.7629279, - size.height * 0.1909091, - size.width * 0.7725676, - size.height * 0.2177273, - size.width * 0.7725676, - size.height * 0.2642424); - path_0.cubicTo( - size.width * 0.7725676, - size.height * 0.3107576, - size.width * 0.7622523, - size.height * 0.3378788, - size.width * 0.7462613, - size.height * 0.3378788); - path_0.close(); - - final paint0Fill = Paint()..style = PaintingStyle.fill; - paint0Fill.color = color; - canvas.drawPath(path_0, paint0Fill); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return true; - } -} diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index c74013e81e..d1ee9c080c 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -18,3 +18,4 @@ export 'src/user_interaction/sentry_user_interaction_widget.dart'; export 'src/binding_wrapper.dart'; export 'src/sentry_widget.dart'; export 'src/navigation/sentry_display_widget.dart'; +export 'src/feedback/sentry_feedback_widget.dart'; diff --git a/flutter/lib/src/feedback/sentry_feedback_widget.dart b/flutter/lib/src/feedback/sentry_feedback_widget.dart new file mode 100644 index 0000000000..3112bb8cee --- /dev/null +++ b/flutter/lib/src/feedback/sentry_feedback_widget.dart @@ -0,0 +1,252 @@ +// ignore_for_file: library_private_types_in_public_api + +import 'package:flutter/material.dart'; +import '../../sentry_flutter.dart'; + +class SentryFeedbackWidget extends StatefulWidget { + SentryFeedbackWidget({ + super.key, + this.associatedEventId, + Hub? hub, + this.title = 'Report a Bug', + this.nameLabel = 'Name', + this.namePlaceholder = 'Your Name', + this.emailLabel = 'Email', + this.emailPlaceholder = 'your.email@example.org', + this.messageLabel = 'Description', + this.messagePlaceholder = 'What\'s the bug? What did you expect?', + this.submitButtonLabel = 'Send Bug Report', + this.cancelButtonLabel = 'Cancel', + this.validationErrorLabel = 'Can\'t be empty', + this.isRequiredLabel = '(required)', + this.isNameRequired = false, + this.isEmailRequired = false, + }) : assert(associatedEventId != const SentryId.empty()), + _hub = hub ?? HubAdapter(); + + final SentryId? associatedEventId; + final Hub _hub; + + final String title; + + final String nameLabel; + final String namePlaceholder; + final String emailLabel; + final String emailPlaceholder; + final String messageLabel; + final String messagePlaceholder; + + final String submitButtonLabel; + final String cancelButtonLabel; + final String validationErrorLabel; + + final String isRequiredLabel; + + final bool isNameRequired; + final bool isEmailRequired; + + @override + _SentryFeedbackWidgetState createState() => _SentryFeedbackWidgetState(); +} + +class _SentryFeedbackWidgetState extends State { + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _messageController = TextEditingController(); + + final GlobalKey _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + key: const ValueKey('sentry_feedback_name_label'), + widget.nameLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(width: 4), + if (widget.isNameRequired) + Text( + key: const ValueKey( + 'sentry_feedback_name_required_label'), + widget.isRequiredLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + TextFormField( + key: const ValueKey('sentry_feedback_name_textfield'), + style: Theme.of(context).textTheme.bodyLarge, + controller: _nameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.namePlaceholder, + ), + keyboardType: TextInputType.text, + validator: (String? value) { + return _errorText(value, widget.isNameRequired); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + key: const ValueKey('sentry_feedback_email_label'), + widget.emailLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(width: 4), + if (widget.isEmailRequired) + Text( + key: const ValueKey( + 'sentry_feedback_email_required_label'), + widget.isRequiredLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + TextFormField( + key: const ValueKey('sentry_feedback_email_textfield'), + controller: _emailController, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.emailPlaceholder, + ), + keyboardType: TextInputType.emailAddress, + validator: (String? value) { + return _errorText(value, widget.isEmailRequired); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + key: + const ValueKey('sentry_feedback_message_label'), + widget.messageLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(width: 4), + Text( + key: const ValueKey( + 'sentry_feedback_message_required_label'), + widget.isRequiredLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + TextFormField( + key: + const ValueKey('sentry_feedback_message_textfield'), + controller: _messageController, + style: Theme.of(context).textTheme.bodyLarge, + minLines: 5, + maxLines: null, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.messagePlaceholder, + ), + keyboardType: TextInputType.multiline, + validator: (String? value) { + return _errorText(value, true); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + key: const ValueKey('sentry_feedback_submit_button'), + onPressed: () async { + if (!_formKey.currentState!.validate()) { + return; + } + final feedback = SentryFeedback( + message: _messageController.text, + contactEmail: _emailController.text, + name: _nameController.text, + associatedEventId: widget.associatedEventId, + ); + await _captureFeedback(feedback); + + bool mounted; + try { + mounted = (this as dynamic).mounted as bool; + } on NoSuchMethodError catch (_) { + mounted = false; + } + if (mounted) { + // ignore: use_build_context_synchronously + await Navigator.maybePop(context); + } + }, + child: Text(widget.submitButtonLabel), + ), + ), + SizedBox( + width: double.infinity, + child: TextButton( + key: const ValueKey('sentry_feedback_close_button'), + onPressed: () { + Navigator.pop(context); + }, + child: Text(widget.cancelButtonLabel), + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + String? _errorText(String? value, bool isRequired) { + if (isRequired && (value == null || value.isEmpty)) { + return widget.validationErrorLabel; + } + return null; + } + + Future _captureFeedback(SentryFeedback feedback) { + return widget._hub.captureFeedback(feedback); + } +} diff --git a/flutter/test/feedback/sentry_feedback_widget_test.dart b/flutter/test/feedback/sentry_feedback_widget_test.dart new file mode 100644 index 0000000000..668b4e247e --- /dev/null +++ b/flutter/test/feedback/sentry_feedback_widget_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$SentryFeedbackWidget validation', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + testWidgets('does not call hub on submit if not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget(hub: hub), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + verifyNever( + fixture.hub.captureFeedback( + captureAny, + hint: anyNamed('hint'), + withScope: anyNamed('withScope'), + ), + ); + }); + + testWidgets('shows error on submit if message not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget(hub: hub), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsOne); + expect(find.text('(required)'), findsOne); + }); + + testWidgets('shows error on submit if name not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + isNameRequired: true, + ), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsExactly(2)); + expect(find.text('(required)'), findsExactly(2)); + }); + + testWidgets('shows error on submit if email not valid', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + isEmailRequired: true, + ), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsExactly(2)); + expect(find.text('(required)'), findsExactly(2)); + }); + + testWidgets('shows error on submit if name and email not valid', + (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + isNameRequired: true, + isEmailRequired: true, + ), + ); + + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + expect(find.text('Can\'t be empty'), findsExactly(3)); + expect(find.text('(required)'), findsExactly(3)); + }); + }); + + group('$SentryFeedbackWidget submit', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + testWidgets('does call hub captureFeedback on submit', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + associatedEventId: + SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea'), + ), + ); + + when(fixture.hub.captureFeedback( + any, + hint: anyNamed('hint'), + withScope: anyNamed('withScope'), + )).thenAnswer( + (_) async => SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea')); + + await tester.enterText( + find.byKey(ValueKey('sentry_feedback_name_textfield')), + "fixture-name"); + await tester.enterText( + find.byKey(ValueKey('sentry_feedback_email_textfield')), + "fixture-email"); + await tester.enterText( + find.byKey(ValueKey('sentry_feedback_message_textfield')), + "fixture-message"); + await tester.tap(find.text('Send Bug Report')); + await tester.pumpAndSettle(); + + verify(fixture.hub.captureFeedback( + argThat(predicate((feedback) => + feedback.name == 'fixture-name' && + feedback.contactEmail == 'fixture-email' && + feedback.message == 'fixture-message' && + feedback.associatedEventId == + SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea'))), + hint: anyNamed('hint'), + withScope: anyNamed('withScope'), + )).called(1); + }); + }); + + group('$SentryFeedbackWidget localization', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + testWidgets('sets labels and hints from parameters', (tester) async { + await fixture.pumpFeedbackWidget( + tester, + (hub) => SentryFeedbackWidget( + hub: hub, + title: 'fixture-title', + nameLabel: 'fixture-nameLabel', + namePlaceholder: 'fixture-namePlaceholder', + emailLabel: 'fixture-emailLabel', + emailPlaceholder: 'fixture-emailPlaceholder', + messageLabel: 'fixture-messageLabel', + messagePlaceholder: 'fixture-messagePlaceholder', + submitButtonLabel: 'fixture-submitButtonLabel', + cancelButtonLabel: 'fixture-cancelButtonLabel', + isRequiredLabel: 'fixture-isRequiredLabel', + validationErrorLabel: 'fixture-validationErrorLabel', + ), + ); + + expect(find.text('fixture-title'), findsOne); + expect(find.text('fixture-nameLabel'), findsOne); + expect(find.text('fixture-namePlaceholder'), findsOne); + expect(find.text('fixture-emailLabel'), findsOne); + expect(find.text('fixture-emailPlaceholder'), findsOne); + expect(find.text('fixture-messageLabel'), findsOne); + expect(find.text('fixture-messagePlaceholder'), findsOne); + expect(find.text('fixture-submitButtonLabel'), findsOne); + expect(find.text('fixture-cancelButtonLabel'), findsOne); + expect(find.text('fixture-isRequiredLabel'), findsOne); + + await tester.tap(find.text('fixture-submitButtonLabel')); + await tester.pumpAndSettle(); + + expect(find.text('fixture-validationErrorLabel'), findsOne); + }); + }); +} + +class Fixture { + var hub = MockHub(); + + Future pumpFeedbackWidget( + WidgetTester tester, Widget Function(Hub) builder) async { + await tester.pumpWidget( + MaterialApp( + home: builder(hub), + ), + ); + } +} diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index 40e2d854c6..72ffddf9b6 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -1171,6 +1171,34 @@ class MockSentryClient extends _i1.Mock implements _i2.SentryClient { returnValueForMissingStub: _i7.Future.value(), ) as _i7.Future); + @override + _i7.Future<_i2.SentryId> captureFeedback( + _i2.SentryFeedback? feedback, { + _i2.Scope? scope, + _i2.Hint? hint, + }) => + (super.noSuchMethod( + Invocation.method( + #captureFeedback, + [feedback], + { + #scope: scope, + #hint: hint, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureFeedback, + [feedback], + { + #scope: scope, + #hint: hint, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + @override _i7.Future<_i2.SentryId> captureMetrics( Map>? metricsBuckets) => @@ -1631,6 +1659,34 @@ class MockHub extends _i1.Mock implements _i2.Hub { returnValueForMissingStub: _i7.Future.value(), ) as _i7.Future); + @override + _i7.Future<_i2.SentryId> captureFeedback( + _i2.SentryFeedback? feedback, { + _i2.Hint? hint, + _i2.ScopeCallback? withScope, + }) => + (super.noSuchMethod( + Invocation.method( + #captureFeedback, + [feedback], + { + #hint: hint, + #withScope: withScope, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureFeedback, + [feedback], + { + #hint: hint, + #withScope: withScope, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + @override _i7.Future addBreadcrumb( _i2.Breadcrumb? crumb, { diff --git a/isar/test/mocks/mocks.mocks.dart b/isar/test/mocks/mocks.mocks.dart index 4f7adfed86..dc4cbd87a4 100644 --- a/isar/test/mocks/mocks.mocks.dart +++ b/isar/test/mocks/mocks.mocks.dart @@ -286,6 +286,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as _i3.Future<_i2.SentryId>); @override + // ignore: deprecated_member_use _i3.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => (super.noSuchMethod( Invocation.method( diff --git a/sqflite/test/mocks/mocks.mocks.dart b/sqflite/test/mocks/mocks.mocks.dart index 6c4c5e362e..11300b2dff 100644 --- a/sqflite/test/mocks/mocks.mocks.dart +++ b/sqflite/test/mocks/mocks.mocks.dart @@ -1453,6 +1453,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { )), ) as _i4.Future<_i2.SentryId>); @override + // ignore: deprecated_member_use _i4.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => (super.noSuchMethod( Invocation.method(