diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index ea436f3a43..9c4755d19f 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -222,6 +222,6 @@ jobs: # Run the tests flutter drive \ - --driver=integration_test/test_driver/web_driver.dart \ + --driver=integration_test/test_driver/driver.dart \ --target=integration_test/web_sdk_test.dart \ -d chrome \ No newline at end of file diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index cbfa514b36..60f72d2672 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -5,60 +5,60 @@ /// A pure Dart client for Sentry.io crash reporting. library sentry_dart; -export 'src/run_zoned_guarded_integration.dart'; +export 'src/event_processor.dart'; +export 'src/exception_cause.dart'; +// exception extraction +export 'src/exception_cause_extractor.dart'; +export 'src/exception_stacktrace_extractor.dart'; +export 'src/exception_type_identifier.dart'; +export 'src/hint.dart'; +export 'src/http_client/sentry_http_client.dart'; +export 'src/http_client/sentry_http_client_error.dart'; export 'src/hub.dart'; // useful for tests export 'src/hub_adapter.dart'; -export 'src/platform_checker.dart'; +export 'src/integration.dart'; export 'src/noop_isolate_error_integration.dart' if (dart.library.io) 'src/isolate_error_integration.dart'; +export 'src/performance_collector.dart'; +export 'src/platform_checker.dart'; export 'src/protocol.dart'; +// feedback +export 'src/protocol/sentry_feedback.dart'; +// proxy +export 'src/protocol/sentry_proxy.dart'; +export 'src/run_zoned_guarded_integration.dart'; export 'src/scope.dart'; export 'src/scope_observer.dart'; export 'src/sentry.dart'; +export 'src/sentry_attachment/sentry_attachment.dart'; +export 'src/sentry_baggage.dart'; +export 'src/sentry_client.dart'; export 'src/sentry_envelope.dart'; export 'src/sentry_envelope_item.dart'; -export 'src/sentry_client.dart'; export 'src/sentry_options.dart'; +// ignore: invalid_export_of_internal_element +export 'src/sentry_span_operations.dart'; +// ignore: invalid_export_of_internal_element +export 'src/sentry_trace_origins.dart'; +export 'src/sentry_user_feedback.dart'; +// constants +export 'src/span_data_convention.dart'; +// spotlight debugging +export 'src/spotlight.dart'; // useful for integrations export 'src/throwable_mechanism.dart'; -export 'src/transport/transport.dart'; -export 'src/integration.dart'; -export 'src/event_processor.dart'; -export 'src/http_client/sentry_http_client.dart'; -export 'src/http_client/sentry_http_client_error.dart'; -export 'src/sentry_attachment/sentry_attachment.dart'; -export 'src/sentry_user_feedback.dart'; -export 'src/utils/tracing_utils.dart'; -export 'src/performance_collector.dart'; // tracing export 'src/tracing.dart'; -export 'src/hint.dart'; +export 'src/transport/transport.dart'; export 'src/type_check_hint.dart'; -export 'src/sentry_baggage.dart'; -// exception extraction -export 'src/exception_cause_extractor.dart'; -export 'src/exception_cause.dart'; -export 'src/exception_stacktrace_extractor.dart'; -export 'src/exception_type_identifier.dart'; -// URL -// ignore: invalid_export_of_internal_element -export 'src/utils/http_sanitizer.dart'; // ignore: invalid_export_of_internal_element -export 'src/utils/url_details.dart'; +export 'src/utils.dart'; // ignore: invalid_export_of_internal_element export 'src/utils/http_header_utils.dart'; +// URL // ignore: invalid_export_of_internal_element -export 'src/sentry_trace_origins.dart'; -// ignore: invalid_export_of_internal_element -export 'src/sentry_span_operations.dart'; +export 'src/utils/http_sanitizer.dart'; +export 'src/utils/tracing_utils.dart'; // ignore: invalid_export_of_internal_element -export 'src/utils.dart'; -// spotlight debugging -export 'src/spotlight.dart'; -// proxy -export 'src/protocol/sentry_proxy.dart'; -// feedback -export 'src/protocol/sentry_feedback.dart'; -// constants -export 'src/span_data_convention.dart'; +export 'src/utils/url_details.dart'; diff --git a/dart/lib/src/client_reports/client_report.dart b/dart/lib/src/client_reports/client_report.dart index 38a1a34c67..d2a51a1a84 100644 --- a/dart/lib/src/client_reports/client_report.dart +++ b/dart/lib/src/client_reports/client_report.dart @@ -1,10 +1,10 @@ import 'package:meta/meta.dart'; +import '../../sentry.dart'; import 'discarded_event.dart'; -import '../utils.dart'; @internal -class ClientReport { +class ClientReport implements SentryEnvelopeItemPayload { ClientReport(this.timestamp, this.discardedEvents); final DateTime? timestamp; @@ -27,4 +27,9 @@ class ClientReport { return json; } + + @override + Future getPayload() { + return Future.value(toJson()); + } } diff --git a/dart/lib/src/platform_checker.dart b/dart/lib/src/platform_checker.dart index 64caa9203f..0784542473 100644 --- a/dart/lib/src/platform_checker.dart +++ b/dart/lib/src/platform_checker.dart @@ -40,20 +40,13 @@ class PlatformChecker { } /// Indicates whether a native integration is available. - bool get hasNativeIntegration { - if (isWeb) { - return false; - } - // We need to check the platform after we checked for web, because - // the OS checks return true when the browser runs on the checked platform. - // Example: platform.isAndroid return true if the browser is used on an - // Android device. - return platform.isAndroid || - platform.isIOS || - platform.isMacOS || - platform.isWindows || - platform.isLinux; - } + bool get hasNativeIntegration => + isWeb || + platform.isAndroid || + platform.isIOS || + platform.isMacOS || + platform.isWindows || + platform.isLinux; static bool _isWebWithWasmSupport() { if (const bool.hasEnvironment(_jsUtil)) { diff --git a/dart/lib/src/protocol/sentry_event.dart b/dart/lib/src/protocol/sentry_event.dart index fe7e0af47f..766ae78061 100644 --- a/dart/lib/src/protocol/sentry_event.dart +++ b/dart/lib/src/protocol/sentry_event.dart @@ -1,14 +1,14 @@ -import 'package:meta/meta.dart'; import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; -import '../protocol.dart'; -import '../throwable_mechanism.dart'; -import '../utils.dart'; +import '../../sentry.dart'; import 'access_aware_map.dart'; /// An event to be reported to Sentry.io. @immutable -class SentryEvent with SentryEventLike { +class SentryEvent + with SentryEventLike + implements SentryEnvelopeItemPayload { /// Creates an event. SentryEvent({ SentryId? eventId, @@ -418,4 +418,9 @@ class SentryEvent with SentryEventLike { SentryStackTrace? get stacktrace => exceptions?.firstWhereOrNull((e) => e.stackTrace != null)?.stackTrace ?? threads?.firstWhereOrNull((t) => t.stacktrace != null)?.stacktrace; + + @override + Future getPayload() { + return Future.value(toJson()); + } } diff --git a/dart/lib/src/sentry_attachment/sentry_attachment.dart b/dart/lib/src/sentry_attachment/sentry_attachment.dart index 9998fc895b..00d6467597 100644 --- a/dart/lib/src/sentry_attachment/sentry_attachment.dart +++ b/dart/lib/src/sentry_attachment/sentry_attachment.dart @@ -1,8 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; -import '../protocol/sentry_view_hierarchy.dart'; -import '../utils.dart'; +import '../../sentry.dart'; // https://develop.sentry.dev/sdk/features/#attachments // https://develop.sentry.dev/sdk/envelopes/#attachment @@ -10,7 +9,7 @@ import '../utils.dart'; typedef ContentLoader = FutureOr Function(); /// Arbitrary content which gets attached to an event. -class SentryAttachment { +class SentryAttachment implements SentryEnvelopeItemPayload { /// Standard attachment without special meaning. static const String typeAttachmentDefault = 'event.attachment'; @@ -122,4 +121,9 @@ class SentryAttachment { /// If true, attachment should be added to every transaction. /// Defaults to false. final bool addToTransactions; + + @override + Future getPayload() async { + return await bytes; + } } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index f5a893b45a..08b8638a7c 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math'; import 'package:meta/meta.dart'; -import 'type_check_hint.dart'; import 'client_reports/client_report_recorder.dart'; import 'client_reports/discard_reason.dart'; @@ -27,6 +26,7 @@ import 'transport/http_transport.dart'; import 'transport/noop_transport.dart'; import 'transport/rate_limiter.dart'; import 'transport/spotlight_http_transport.dart'; +import 'type_check_hint.dart'; import 'utils/isolate_utils.dart'; import 'utils/regex_utils.dart'; import 'utils/stacktrace_utils.dart'; diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index a671730fb6..980f815267 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -10,10 +10,14 @@ import 'sentry_item_type.dart'; import 'sentry_user_feedback.dart'; import 'utils.dart'; +abstract class SentryEnvelopeItemPayload { + Future getPayload(); +} + /// Item holding header information and JSON encoded data. class SentryEnvelopeItem { /// The original, non-encoded object, used when direct access to the source data is needed. - Object? originalObject; + SentryEnvelopeItemPayload? originalObject; SentryEnvelopeItem(this.header, this.dataFactory, {this.originalObject}); @@ -102,7 +106,6 @@ class SentryEnvelopeItem { return SentryEnvelopeItem( header, dataFactory, - originalObject: buckets, ); } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index fa30ca8117..b744f1a1c9 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -276,7 +276,7 @@ class SentryOptions { /// breadcrumbs. /// In a Flutter environment, this setting also toggles recording of `debugPrint` calls. /// `debugPrint` calls are only recorded in release builds, though. - bool enablePrintBreadcrumbs = true; + bool enablePrintBreadcrumbs = false; /// If [platformChecker] is provided, it is used get the environment. /// This is useful in tests. Should be an implementation of [PlatformChecker]. diff --git a/dart/lib/src/sentry_user_feedback.dart b/dart/lib/src/sentry_user_feedback.dart index 722a0983f1..576e699cd9 100644 --- a/dart/lib/src/sentry_user_feedback.dart +++ b/dart/lib/src/sentry_user_feedback.dart @@ -1,10 +1,10 @@ import 'package:meta/meta.dart'; -import 'protocol.dart'; +import '../sentry.dart'; import 'protocol/access_aware_map.dart'; @Deprecated('Will be removed in a future version. Use [SentryFeedback] instead') -class SentryUserFeedback { +class SentryUserFeedback implements SentryEnvelopeItemPayload { SentryUserFeedback({ required this.eventId, this.name, @@ -66,4 +66,9 @@ class SentryUserFeedback { unknown: unknown, ); } + + @override + Future getPayload() { + return Future.value(toJson()); + } } diff --git a/dart/lib/src/transport/http_transport.dart b/dart/lib/src/transport/http_transport.dart index 45d201f791..551d728c2a 100644 --- a/dart/lib/src/transport/http_transport.dart +++ b/dart/lib/src/transport/http_transport.dart @@ -2,17 +2,17 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart'; -import '../utils/transport_utils.dart'; -import 'http_transport_request_handler.dart'; +import '../http_client/client_provider.dart' + if (dart.library.io) '../http_client/io_client_provider.dart'; import '../noop_client.dart'; import '../protocol.dart'; -import '../sentry_options.dart'; import '../sentry_envelope.dart'; -import 'transport.dart'; +import '../sentry_options.dart'; +import '../utils/transport_utils.dart'; +import 'http_transport_request_handler.dart'; import 'rate_limiter.dart'; -import '../http_client/client_provider.dart' - if (dart.library.io) '../http_client/io_client_provider.dart'; +import 'transport.dart'; /// A transport is in charge of sending the event to the Sentry server. class HttpTransport implements Transport { diff --git a/flutter/example/integration_test/test_driver/web_driver.dart b/flutter/example/integration_test/test_driver/web_driver.dart deleted file mode 100644 index b38629cca9..0000000000 --- a/flutter/example/integration_test/test_driver/web_driver.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:integration_test/integration_test_driver.dart'; - -Future main() => integrationDriver(); diff --git a/flutter/example/integration_test/utils.dart b/flutter/example/integration_test/utils.dart index 951bb84a64..3e1b42d2d7 100644 --- a/flutter/example/integration_test/utils.dart +++ b/flutter/example/integration_test/utils.dart @@ -20,3 +20,5 @@ FutureOr restoreFlutterOnErrorAfter(FutureOr Function() fn) async { originalOnError?.call(details); }; } + +const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; diff --git a/flutter/example/integration_test/web_sdk_test.dart b/flutter/example/integration_test/web_sdk_test.dart index d4d876820f..5247ff88dc 100644 --- a/flutter/example/integration_test/web_sdk_test.dart +++ b/flutter/example/integration_test/web_sdk_test.dart @@ -4,17 +4,22 @@ library flutter_test; import 'dart:async'; -import 'dart:js'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/web/web_sentry_js_binding.dart'; import 'package:sentry_flutter_example/main.dart' as app; import 'utils.dart'; -// We can use dart:html, this is meant to be tested on Flutter Web and not WASM -// This integration test can be changed later when we actually do support WASM +@JS('globalThis') +external JSObject get globalThis; + +@JS('Sentry.getClient') +external JSObject? _getClient(); void main() { group('Web SDK Integration', () { @@ -24,41 +29,181 @@ void main() { await Sentry.close(); }); - testWidgets('Sentry JS SDK is callable', (tester) async { - final completer = Completer(); - const expectedMessage = 'test message'; - String actualMessage = ''; - - await restoreFlutterOnErrorAfter(() async { - await SentryFlutter.init((options) { - options.dsn = app.exampleDsn; - options.automatedTestMode = false; - }, appRunner: () async { - await tester.pumpWidget(const app.MyApp()); + group('enabled', () { + testWidgets('Sentry JS SDK initialized', (tester) async { + await restoreFlutterOnErrorAfter(() async { + await SentryFlutter.init((options) { + options.enableSentryJs = true; + options.dsn = fakeDsn; + }, appRunner: () async { + await tester.pumpWidget(const app.MyApp()); + }); }); - final beforeSendFn = JsFunction.withThis((thisArg, event, hint) { - actualMessage = event['message']; + expect(globalThis['Sentry'], isNotNull); + + final client = _getClient()!; + final options = client.callMethod('getOptions'.toJS)! as JSObject; + + final dsn = options.getProperty('dsn'.toJS).toString(); + final defaultIntegrations = options + .getProperty('defaultIntegrations'.toJS) + .dartify() as List; + + expect(dsn, fakeDsn); + expect(defaultIntegrations, isEmpty); + }); + + testWidgets( + 'capture unhandled exception: session contains status crashed', + (tester) async { + await restoreFlutterOnErrorAfter(() async { + await SentryFlutter.init((options) { + options.enableSentryJs = true; + options.dsn = fakeDsn; + }, appRunner: () async { + await tester.pumpWidget(const app.MyApp()); + }); + }); + + final completer = Completer(); + SentryJsBridge.getClient().onBeforeEnvelope((envelope) { + final envelopeDart = envelope.dartify() as List; + + final sessionEnvelope = envelopeDart.firstWhere((el) { + if (el is List) { + return (((el[0] as List)[0]) as Map) + .containsValue('session'); + } else { + return false; + } + }, orElse: () => null); + + expect(sessionEnvelope, isNotNull); + final content = + ((sessionEnvelope as List)[0][1] as Map); + expect(content['status'], 'crashed'); + completer.complete(); - return event; }); - final Map options = { - 'dsn': app.exampleDsn, - 'beforeSend': beforeSendFn, - 'defaultIntegrations': [], - }; + final mechanism = Mechanism(type: 'FlutterError', handled: false); + final throwableMechanism = + ThrowableMechanism(mechanism, Exception('test exception')); - final sentry = context['Sentry'] as JsObject; - sentry.callMethod('init', [JsObject.jsify(options)]); - sentry.callMethod('captureMessage', [expectedMessage]); + await Sentry.captureException(throwableMechanism); + + await completer.future.timeout(const Duration(seconds: 5), + onTimeout: () { + fail('beforeEnvelope was not triggered'); + }); }); - await completer.future.timeout(const Duration(seconds: 5), onTimeout: () { - fail('beforeSend was not triggered'); + testWidgets('capture handled exception: session contains status ok', + (tester) async { + await restoreFlutterOnErrorAfter(() async { + await SentryFlutter.init((options) { + options.enableSentryJs = true; + options.dsn = fakeDsn; + }, appRunner: () async { + await tester.pumpWidget(const app.MyApp()); + }); + }); + + final completer = Completer(); + SentryJsBridge.getClient().onBeforeEnvelope((envelope) { + final envelopeDart = envelope.dartify() as List; + + final sessionEnvelope = envelopeDart.firstWhere((el) { + if (el is List) { + return (((el[0] as List)[0]) as Map) + .containsValue('session'); + } else { + return false; + } + }, orElse: () => null); + + expect(sessionEnvelope, isNotNull); + final content = + ((sessionEnvelope as List)[0][1] as Map); + expect(content['status'], 'ok'); + + completer.complete(); + }); + + await Sentry.captureException(Exception('test exception')); + + await completer.future.timeout(const Duration(seconds: 5), + onTimeout: () { + fail('beforeEnvelope was not triggered'); + }); + }); + + testWidgets( + 'when capturing exceptions then session contains error counts', + (tester) async { + await restoreFlutterOnErrorAfter(() async { + await SentryFlutter.init((options) { + options.enableSentryJs = true; + options.dsn = fakeDsn; + }, appRunner: () async { + await tester.pumpWidget(const app.MyApp()); + }); + }); + + final completer = Completer(); + SentryJsBridge.getClient().onBeforeEnvelope((envelope) { + final envelopeDart = envelope.dartify() as List; + + final sessionEnvelope = envelopeDart.firstWhere((el) { + if (el is List) { + return (((el[0] as List)[0]) as Map) + .containsValue('session'); + } else { + return false; + } + }, orElse: () => null); + + expect(sessionEnvelope, isNotNull); + final content = + ((sessionEnvelope as List)[0][1] as Map); + expect(content['status'], 'ok'); + + completer.complete(); + }); + + await Sentry.captureException(Exception('test exception')); + await Sentry.captureException(Exception('test exception')); + await Sentry.captureException(Exception('test exception')); + await Sentry.captureException(Exception('test exception')); + + await completer.future.timeout(const Duration(seconds: 5), + onTimeout: () { + fail('beforeEnvelope was not triggered'); + }); }); + }); - expect(actualMessage, equals(expectedMessage)); + group('disabled', () { + testWidgets('Sentry JS SDK is not initialized', (tester) async { + await restoreFlutterOnErrorAfter(() async { + await SentryFlutter.init((options) { + options.enableSentryJs = false; + options.dsn = fakeDsn; + }, appRunner: () async { + await tester.pumpWidget(const app.MyApp()); + }); + }); + + expect(globalThis['Sentry'], isNull); + expect(() => _getClient(), throwsA(anything)); + }); }); }); } + +extension SentryJsClientHelpers on SentryJsClient { + void onBeforeEnvelope(void Function(JSArray envelope) callback) { + on('beforeEnvelope'.toJS, callback.toJS); + } +} diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index d5c7d71d71..26648c9d83 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -83,6 +83,7 @@ Future setupSentry( options.spotlight = Spotlight(enabled: true); options.enableTimeToFullDisplayTracing = true; options.enableMetrics = true; + options.enableSentryJs = true; options.maxRequestBodySize = MaxRequestBodySize.always; options.maxResponseBodySize = MaxResponseBodySize.always; diff --git a/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart b/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart index 0ea1b731d6..dae174f657 100644 --- a/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart +++ b/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart @@ -37,8 +37,11 @@ class FlutterEnricherEventProcessor implements EventProcessor { ) async { // If there's a native integration available, it probably has better // information available than Flutter. - final device = - _hasNativeIntegration ? null : _getDevice(event.contexts.device); + // TODO: while we have a native integration with JS SDK, it's currently opt in and we dont gather contexts yet + // so for web it's still better to rely on the information of Flutter. + final device = _hasNativeIntegration && !_checker.isWeb + ? null + : _getDevice(event.contexts.device); final contexts = event.contexts.copyWith( device: device, diff --git a/flutter/lib/src/integrations/integrations.dart b/flutter/lib/src/integrations/integrations.dart index ac16732f58..f533e4e282 100644 --- a/flutter/lib/src/integrations/integrations.dart +++ b/flutter/lib/src/integrations/integrations.dart @@ -4,7 +4,7 @@ export 'load_contexts_integration.dart'; export 'load_image_list_integration.dart'; export 'load_release_integration.dart'; export 'native_app_start_integration.dart'; -export 'native_sdk_integration.dart'; export 'on_error_integration.dart'; +export 'sdk_integration.dart'; export 'widgets_binding_integration.dart'; export 'widgets_flutter_binding_integration.dart'; diff --git a/flutter/lib/src/integrations/load_release_integration.dart b/flutter/lib/src/integrations/load_release_integration.dart index ca8a29d11e..43af1e28fd 100644 --- a/flutter/lib/src/integrations/load_release_integration.dart +++ b/flutter/lib/src/integrations/load_release_integration.dart @@ -2,12 +2,11 @@ import 'dart:async'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry/sentry.dart'; + import '../sentry_flutter_options.dart'; /// An [Integration] that loads the release version from native apps class LoadReleaseIntegration extends Integration { - LoadReleaseIntegration(); - @override Future call(Hub hub, SentryFlutterOptions options) async { try { diff --git a/flutter/lib/src/integrations/native_sdk_integration.dart b/flutter/lib/src/integrations/native_sdk_integration.dart index 4c7c9a92a4..c9f6ac39f2 100644 --- a/flutter/lib/src/integrations/native_sdk_integration.dart +++ b/flutter/lib/src/integrations/native_sdk_integration.dart @@ -1,9 +1,15 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; + import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; +Integration createSdkIntegration( + SentryNativeBinding native) { + return NativeSdkIntegration(native); +} + /// Enables Sentry's native SDKs (Android and iOS) with options. class NativeSdkIntegration implements Integration { NativeSdkIntegration(this._native); diff --git a/flutter/lib/src/integrations/sdk_integration.dart b/flutter/lib/src/integrations/sdk_integration.dart new file mode 100644 index 0000000000..c0ca43dc0d --- /dev/null +++ b/flutter/lib/src/integrations/sdk_integration.dart @@ -0,0 +1,3 @@ +export 'native_sdk_integration.dart' + if (dart.library.html) 'web_sdk_integration.dart' + if (dart.library.js_interop) 'web_sdk_integration.dart'; diff --git a/flutter/lib/src/integrations/web_sdk_integration.dart b/flutter/lib/src/integrations/web_sdk_integration.dart index 24e0838d03..c65066474e 100644 --- a/flutter/lib/src/integrations/web_sdk_integration.dart +++ b/flutter/lib/src/integrations/web_sdk_integration.dart @@ -1,32 +1,53 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'package:sentry/sentry.dart'; -import '../../sentry_flutter.dart'; +import '../native/sentry_native_binding.dart'; +import '../sentry_flutter_options.dart'; +import '../transport/javascript_transport.dart'; import '../web/script_loader/sentry_script_loader.dart'; import '../web/sentry_js_bundle.dart'; +Integration createSdkIntegration( + SentryNativeBinding native) { + final scriptLoader = SentryScriptLoader(); + return WebSdkIntegration(native, scriptLoader); +} + class WebSdkIntegration implements Integration { - WebSdkIntegration(this._scriptLoader); + WebSdkIntegration(this._web, this._scriptLoader); + final SentryNativeBinding _web; final SentryScriptLoader _scriptLoader; + SentryFlutterOptions? _options; @internal static const name = 'webSdkIntegration'; @override FutureOr call(Hub hub, SentryFlutterOptions options) async { + if (!options.enableSentryJs || !options.autoInitializeNativeSdk) { + return; + } + + _options = options; + try { final scripts = options.platformChecker.isDebugMode() ? debugScripts : productionScripts; await _scriptLoader.loadWebSdk(scripts); + await _web.init(hub); + + // todo: we can move this to sentry_flutter.dart once using the native web integration is the default + options.transport = JavascriptTransport(_web, options); options.sdk.addIntegration(name); } catch (exception, stackTrace) { options.logger( SentryLevel.fatal, - '$name failed to be installed', + '$name failed to be installed.', exception: exception, stackTrace: stackTrace, ); @@ -37,7 +58,16 @@ class WebSdkIntegration implements Integration { } @override - FutureOr close() { - // no-op + FutureOr close() async { + try { + await _web.close(); + await _scriptLoader.close(); + } catch (error, stackTrace) { + _options?.logger(SentryLevel.warning, '$name failed to be closed.', + exception: error, stackTrace: stackTrace); + if (_options?.automatedTestMode == true) { + rethrow; + } + } } } diff --git a/flutter/lib/src/native/c/sentry_native.dart b/flutter/lib/src/native/c/sentry_native.dart index 788a052cae..7fe0c8ab2d 100644 --- a/flutter/lib/src/native/c/sentry_native.dart +++ b/flutter/lib/src/native/c/sentry_native.dart @@ -101,7 +101,7 @@ class SentryNative with SentryNativeSafeInvoker implements SentryNativeBinding { @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - throw UnsupportedError('$SentryNative.captureEnvelope() is not suppurted'); + throw UnsupportedError('$SentryNative.captureEnvelope() is not supported'); } @override @@ -282,6 +282,11 @@ class SentryNative with SentryNativeSafeInvoker implements SentryNativeBinding { _logNotSupported('capturing replay'); return SentryId.empty(); } + + @override + FutureOr captureEnvelopeObject(SentryEnvelope envelope) { + throw UnsupportedError("Not supported on this platform"); + } } extension on binding.sentry_value_u { diff --git a/flutter/lib/src/native/factory_web.dart b/flutter/lib/src/native/factory_web.dart index 17c3f5afe0..85d3ff9b8a 100644 --- a/flutter/lib/src/native/factory_web.dart +++ b/flutter/lib/src/native/factory_web.dart @@ -1,7 +1,9 @@ import '../../sentry_flutter.dart'; +import '../web/sentry_js_binding.dart'; +import '../web/sentry_web.dart'; import 'sentry_native_binding.dart'; -// This isn't actually called, see SentryFlutter.init() SentryNativeBinding createBinding(SentryFlutterOptions options) { - throw UnsupportedError("Native binding is not supported on this platform."); + final binding = createJsBinding(); + return SentryWeb(binding, options); } diff --git a/flutter/lib/src/native/sentry_native_binding.dart b/flutter/lib/src/native/sentry_native_binding.dart index 0a00f385be..5f1d328012 100644 --- a/flutter/lib/src/native/sentry_native_binding.dart +++ b/flutter/lib/src/native/sentry_native_binding.dart @@ -22,6 +22,8 @@ abstract class SentryNativeBinding { FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException); + FutureOr captureEnvelopeObject(SentryEnvelope envelope); + FutureOr beginNativeFrames(); FutureOr endNativeFrames(SentryId id); diff --git a/flutter/lib/src/native/sentry_native_channel.dart b/flutter/lib/src/native/sentry_native_channel.dart index 0fc1435b42..c805184456 100644 --- a/flutter/lib/src/native/sentry_native_channel.dart +++ b/flutter/lib/src/native/sentry_native_channel.dart @@ -8,9 +8,9 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import '../replay/replay_config.dart'; +import 'method_channel_helper.dart'; import 'native_app_start.dart'; import 'native_frames.dart'; -import 'method_channel_helper.dart'; import 'sentry_native_binding.dart'; import 'sentry_native_invoker.dart'; import 'sentry_safe_method_channel.dart'; @@ -234,4 +234,9 @@ class SentryNativeChannel channel.invokeMethod('captureReplay', { 'isCrash': isCrash, }).then((value) => SentryId.fromId(value as String)); + + @override + FutureOr captureEnvelopeObject(SentryEnvelope envelope) { + throw UnsupportedError("Not supported on this platform"); + } } diff --git a/flutter/lib/src/navigation/sentry_navigator_observer.dart b/flutter/lib/src/navigation/sentry_navigator_observer.dart index 6717a425d0..03cc81db39 100644 --- a/flutter/lib/src/navigation/sentry_navigator_observer.dart +++ b/flutter/lib/src/navigation/sentry_navigator_observer.dart @@ -5,17 +5,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; +// ignore: implementation_imports +import 'package:sentry/src/sentry_tracer.dart'; + +import '../../sentry_flutter.dart'; +import '../event_processor/flutter_enricher_event_processor.dart'; import '../native/native_frames.dart'; import '../native/sentry_native_binding.dart'; import 'time_to_display_tracker.dart'; import 'time_to_full_display_tracker.dart'; -import '../../sentry_flutter.dart'; -import '../event_processor/flutter_enricher_event_processor.dart'; - -// ignore: implementation_imports -import 'package:sentry/src/sentry_tracer.dart'; - /// This key must be used so that the web interface displays the events nicely /// See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/ const _navigationKey = 'navigation'; diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index cbfea7b25b..096f3553c0 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -12,7 +12,6 @@ import 'event_processor/platform_exception_event_processor.dart'; import 'event_processor/screenshot_event_processor.dart'; import 'event_processor/url_filter/url_filter_event_processor.dart'; import 'event_processor/widget_event_processor.dart'; -import 'file_system_transport.dart'; import 'flutter_exception_type_identifier.dart'; import 'frame_callback_handler.dart'; import 'integrations/connectivity/connectivity_integration.dart'; @@ -20,16 +19,15 @@ import 'integrations/frames_tracking_integration.dart'; import 'integrations/integrations.dart'; import 'integrations/native_app_start_handler.dart'; import 'integrations/screenshot_integration.dart'; -import 'integrations/web_sdk_integration.dart'; import 'native/factory.dart'; import 'native/native_scope_observer.dart'; import 'native/sentry_native_binding.dart'; import 'profiling.dart'; import 'renderer/renderer.dart'; import 'replay/integration.dart'; +import 'transport/file_system_transport.dart'; import 'version.dart'; import 'view_hierarchy/view_hierarchy_integration.dart'; -import 'web/script_loader/sentry_script_loader.dart'; /// Configuration options callback typedef FlutterOptionsConfiguration = FutureOr Function( @@ -125,9 +123,16 @@ mixin SentryFlutter { // Not all platforms have a native integration. if (_native != null) { if (_native!.supportsCaptureEnvelope) { - options.transport = FileSystemTransport(_native!, options); + // Sentry's native web integration is only enabled when enableSentryJs=true. + // Transport configuration happens in web_integration because the configuration + // options aren't available until after the options callback executes. + if (!options.platformChecker.isWeb) { + options.transport = FileSystemTransport(_native!, options); + } + } + if (!options.platformChecker.isWeb) { + options.addScopeObserver(NativeScopeObserver(_native!)); } - options.addScopeObserver(NativeScopeObserver(_native!)); } options.addEventProcessor(FlutterEnricherEventProcessor(options)); @@ -168,24 +173,30 @@ mixin SentryFlutter { // This tracks Flutter application events, such as lifecycle events. integrations.add(WidgetsBindingIntegration()); + // This is an Integration because we want to execute it after all the + // error handlers are in place. Calling a MethodChannel might result + // in errors. + integrations.add(LoadReleaseIntegration()); + // The ordering here matters, as we'd like to first start the native integration. // That allow us to send events to the network and then the Flutter integrations. - // Flutter Web doesn't need that, only Android and iOS. final native = _native; if (native != null) { - integrations.add(NativeSdkIntegration(native)); - if (native.supportsLoadContexts) { - integrations.add(LoadContextsIntegration(native)); + integrations.add(createSdkIntegration(native)); + if (!platformChecker.isWeb) { + if (native.supportsLoadContexts) { + integrations.add(LoadContextsIntegration(native)); + } + integrations.add(LoadImageListIntegration(native)); + integrations.add(FramesTrackingIntegration(native)); + integrations.add( + NativeAppStartIntegration( + DefaultFrameCallbackHandler(), + NativeAppStartHandler(native), + ), + ); + integrations.add(ReplayIntegration(native)); } - integrations.add(LoadImageListIntegration(native)); - integrations.add(FramesTrackingIntegration(native)); - integrations.add( - NativeAppStartIntegration( - DefaultFrameCallbackHandler(), - NativeAppStartHandler(native), - ), - ); - integrations.add(ReplayIntegration(native)); options.enableDartSymbolication = false; } @@ -195,8 +206,6 @@ mixin SentryFlutter { } if (platformChecker.isWeb) { - final loader = SentryScriptLoader(options); - integrations.add(WebSdkIntegration(loader)); integrations.add(ConnectivityIntegration()); } @@ -205,11 +214,6 @@ mixin SentryFlutter { integrations.add(DebugPrintIntegration()); - // This is an Integration because we want to execute it after all the - // error handlers are in place. Calling a MethodChannel might result - // in errors. - integrations.add(LoadReleaseIntegration()); - return integrations; } diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 5d95eb4817..4c92b88cf6 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -3,15 +3,15 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart' as meta; import 'package:sentry/sentry.dart'; -import 'package:flutter/widgets.dart'; import 'binding_wrapper.dart'; +import 'event_processor/screenshot_event_processor.dart'; import 'navigation/time_to_display_tracker.dart'; import 'renderer/renderer.dart'; import 'screenshot/sentry_screenshot_quality.dart'; -import 'event_processor/screenshot_event_processor.dart'; import 'sentry_flutter.dart'; import 'sentry_privacy_options.dart'; import 'sentry_replay_options.dart'; @@ -292,6 +292,13 @@ class SentryFlutterOptions extends SentryOptions { /// you must use `SentryWidgetsFlutterBinding.ensureInitialized()` instead. bool enableFramesTracking = true; + /// Controls initialization of the Sentry Javascript SDK on web platforms. + /// When enabled and [autoInitializeNativeSdk] is true, loads and initializes + /// the JS SDK in the document head. + /// + /// Defaults to `false` + bool enableSentryJs = false; + /// By using this, you are disabling native [Breadcrumb] tracking and instead /// you are just tracking [Breadcrumb]s which result from events available /// in the current Flutter environment. diff --git a/flutter/lib/src/file_system_transport.dart b/flutter/lib/src/transport/file_system_transport.dart similarity index 92% rename from flutter/lib/src/file_system_transport.dart rename to flutter/lib/src/transport/file_system_transport.dart index e28f81ae78..f1d4e4b73e 100644 --- a/flutter/lib/src/file_system_transport.dart +++ b/flutter/lib/src/transport/file_system_transport.dart @@ -4,8 +4,8 @@ import 'dart:typed_data'; import 'package:flutter/services.dart'; -import '../sentry_flutter.dart'; -import 'native/sentry_native_binding.dart'; +import '../../sentry_flutter.dart'; +import '../native/sentry_native_binding.dart'; class FileSystemTransport implements Transport { FileSystemTransport(this._native, this._options); diff --git a/flutter/lib/src/transport/javascript_transport.dart b/flutter/lib/src/transport/javascript_transport.dart new file mode 100644 index 0000000000..9c6b8bb7ad --- /dev/null +++ b/flutter/lib/src/transport/javascript_transport.dart @@ -0,0 +1,26 @@ +import '../../sentry_flutter.dart'; +import '../native/sentry_native_binding.dart'; + +class JavascriptTransport implements Transport { + JavascriptTransport(this._binding, this._options); + + final SentryFlutterOptions _options; + final SentryNativeBinding _binding; + + @override + Future send(SentryEnvelope envelope) async { + try { + await _binding.captureEnvelopeObject(envelope); + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'Failed to send envelope', + exception: exception, + stackTrace: stackTrace, + ); + return Future.value(SentryId.empty()); + } + + return envelope.header.eventId; + } +} diff --git a/flutter/lib/src/web/html_sentry_js_binding.dart b/flutter/lib/src/web/html_sentry_js_binding.dart new file mode 100644 index 0000000000..885441a4c7 --- /dev/null +++ b/flutter/lib/src/web/html_sentry_js_binding.dart @@ -0,0 +1,45 @@ +import 'dart:js'; + +import 'sentry_js_binding.dart'; + +SentryJsBinding createJsBinding() { + return HtmlSentryJsBinding(); +} + +class HtmlSentryJsBinding implements SentryJsBinding { + HtmlSentryJsBinding({JsObject? sentry}) : _sentry = sentry; + + JsObject? _sentry; + + @override + void init(Map options) { + _sentry ??= context['Sentry'] as JsObject; + _sentry!.callMethod('init', [JsObject.jsify(options)]); + } + + @override + void captureEnvelope(List envelope) { + // _sentry? + // _getClient()?.callMethod('sendEnvelope'.toJS, envelope.jsify()); + } + + @override + void close() { + if (_sentry != null) { + _sentry?.callMethod('close'); + _sentry = null; + context['Sentry'] = null; + } + } + + @override + void captureSession() { + // TODO: implement captureSession + } + + @override + getSession() { + // TODO: implement getSession + throw UnimplementedError(); + } +} diff --git a/flutter/lib/src/web/noop_sentry_js_binding.dart b/flutter/lib/src/web/noop_sentry_js_binding.dart new file mode 100644 index 0000000000..c217690d3e --- /dev/null +++ b/flutter/lib/src/web/noop_sentry_js_binding.dart @@ -0,0 +1,24 @@ +import 'sentry_js_binding.dart'; + +SentryJsBinding createJsBinding() { + return NoOpSentryJsBinding(); +} + +class NoOpSentryJsBinding implements SentryJsBinding { + NoOpSentryJsBinding(); + + @override + void init(Map options) {} + + @override + void close() {} + + @override + void captureSession() {} + + @override + getSession() {} + + @override + void captureEnvelope(List envelope) {} +} diff --git a/flutter/lib/src/web/script_loader/html_script_dom_api.dart b/flutter/lib/src/web/script_loader/html_script_dom_api.dart index d2b4d46d7f..8c702e785f 100644 --- a/flutter/lib/src/web/script_loader/html_script_dom_api.dart +++ b/flutter/lib/src/web/script_loader/html_script_dom_api.dart @@ -3,6 +3,7 @@ import 'dart:html'; import 'dart:js_util' as js_util; import '../../../sentry_flutter.dart'; +import 'script_dom_api.dart'; import 'sentry_script_loader.dart'; Future loadScript(String src, SentryOptions options, @@ -57,3 +58,27 @@ Future loadScript(String src, SentryOptions options, } return completer.future; } + +class _ScriptElement implements SentryScriptElement { + final ScriptElement element; + + _ScriptElement(this.element); + + @override + void remove() { + element.remove(); + } + + @override + String get src => element.src; + + @override + String? get integrity => element.integrity; +} + +List fetchScripts(String query) { + final scripts = document.querySelectorAll(query); + return scripts + .map((script) => _ScriptElement(script as ScriptElement)) + .toList(); +} diff --git a/flutter/lib/src/web/script_loader/noop_script_dom_api.dart b/flutter/lib/src/web/script_loader/noop_script_dom_api.dart index 02cf539440..8f7efe559c 100644 --- a/flutter/lib/src/web/script_loader/noop_script_dom_api.dart +++ b/flutter/lib/src/web/script_loader/noop_script_dom_api.dart @@ -1,6 +1,9 @@ import '../../../sentry_flutter.dart'; +import 'script_dom_api.dart'; import 'sentry_script_loader.dart'; Future loadScript(String src, SentryOptions options, {String? integrity, String trustedTypePolicyName = defaultTrustedPolicyName}) async {} + +List fetchScripts(String query) => []; diff --git a/flutter/lib/src/web/script_loader/script_dom_api.dart b/flutter/lib/src/web/script_loader/script_dom_api.dart index 62beb3e8c1..bca967d4d3 100644 --- a/flutter/lib/src/web/script_loader/script_dom_api.dart +++ b/flutter/lib/src/web/script_loader/script_dom_api.dart @@ -1,3 +1,12 @@ +import 'package:meta/meta.dart'; + export 'noop_script_dom_api.dart' if (dart.library.html) 'html_script_dom_api.dart' if (dart.library.js_interop) 'web_script_dom_api.dart'; + +@internal +abstract class SentryScriptElement { + String get src; + String? get integrity; + void remove(); +} diff --git a/flutter/lib/src/web/script_loader/sentry_script_loader.dart b/flutter/lib/src/web/script_loader/sentry_script_loader.dart index 9099f64423..0c21d7a144 100644 --- a/flutter/lib/src/web/script_loader/sentry_script_loader.dart +++ b/flutter/lib/src/web/script_loader/sentry_script_loader.dart @@ -3,15 +3,19 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; +import '../sentry_js_bundle.dart'; import 'script_dom_api.dart'; @internal const String defaultTrustedPolicyName = 'sentry-dart'; class SentryScriptLoader { - SentryScriptLoader(this._options); + SentryScriptLoader({SentryOptions? options}) + : + // ignore: invalid_use_of_internal_member + _options = options ?? Sentry.currentHub.options; - final SentryFlutterOptions _options; + final SentryOptions _options; bool _scriptLoaded = false; /// Loads the scripts into the web page with support for Trusted Types security policy. @@ -45,11 +49,27 @@ class SentryScriptLoader { 'JS SDK integration: all Sentry scripts loaded successfully.'); } catch (e) { _options.logger(SentryLevel.error, 'Failed to load Sentry scripts: $e'); + // ignore: invalid_use_of_internal_member if (_options.automatedTestMode) { rethrow; } } } + + Future close() async { + final scriptsToRemove = _options.platformChecker.isReleaseMode() + ? productionScripts + : debugScripts; + + // no risk of injection since the scripts are constants + final selectors = scriptsToRemove.map((script) { + return 'script[src="${script['url']}"][integrity="${script['integrity']}"]'; + }).join(', '); + final sentryScripts = fetchScripts(selectors); + for (final script in sentryScripts) { + script.remove(); + } + } } /// Exception thrown if the Trusted Types feature is supported, enabled, and it diff --git a/flutter/lib/src/web/script_loader/web_script_dom_api.dart b/flutter/lib/src/web/script_loader/web_script_dom_api.dart index 92fc748059..ef02adabe1 100644 --- a/flutter/lib/src/web/script_loader/web_script_dom_api.dart +++ b/flutter/lib/src/web/script_loader/web_script_dom_api.dart @@ -6,6 +6,7 @@ import 'dart:js_interop_unsafe'; import 'package:web/web.dart'; import '../../../sentry_flutter.dart'; +import 'script_dom_api.dart'; import 'sentry_script_loader.dart'; Future loadScript(String src, SentryOptions options, @@ -53,3 +54,32 @@ Future loadScript(String src, SentryOptions options, } return completer.future; } + +class _ScriptElement implements SentryScriptElement { + final HTMLScriptElement element; + + _ScriptElement(this.element); + + @override + void remove() { + element.remove(); + } + + @override + String get src => element.src; + + @override + String? get integrity => element.integrity; +} + +List fetchScripts(String query) { + final scripts = document.querySelectorAll(query); + + List elements = []; + for (int i = 0; i < scripts.length; i++) { + final node = scripts.item(i); + elements.add(_ScriptElement(node as HTMLScriptElement)); + } + + return elements; +} diff --git a/flutter/lib/src/web/sentry_js_binding.dart b/flutter/lib/src/web/sentry_js_binding.dart new file mode 100644 index 0000000000..371f9b37c5 --- /dev/null +++ b/flutter/lib/src/web/sentry_js_binding.dart @@ -0,0 +1,11 @@ +export 'noop_sentry_js_binding.dart' + if (dart.html) 'html_sentry_js_binding.dart' + if (dart.library.js_interop) 'web_sentry_js_binding.dart'; + +abstract class SentryJsBinding { + void init(Map options); + void captureEnvelope(List envelope); + dynamic getSession(); + void captureSession(); + void close(); +} diff --git a/flutter/lib/src/web/sentry_web.dart b/flutter/lib/src/web/sentry_web.dart new file mode 100644 index 0000000000..f6ca4f82c9 --- /dev/null +++ b/flutter/lib/src/web/sentry_web.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import '../../sentry_flutter.dart'; +import '../native/native_app_start.dart'; +import '../native/native_frames.dart'; +import '../native/sentry_native_binding.dart'; +import '../native/sentry_native_invoker.dart'; +import '../replay/replay_config.dart'; +import 'sentry_js_binding.dart'; + +class SentryWeb with SentryNativeSafeInvoker implements SentryNativeBinding { + SentryWeb(this._binding, this._options); + + final SentryJsBinding _binding; + final SentryFlutterOptions _options; + + void _logNotSupported(String operation) => options.logger( + SentryLevel.debug, 'SentryWeb: $operation is not supported'); + + @override + FutureOr init(Hub hub) { + tryCatchSync('init', () { + final Map jsOptions = { + 'dsn': _options.dsn, + 'debug': _options.debug, + 'environment': _options.environment, + 'release': _options.release, + 'dist': _options.dist, + 'sampleRate': _options.sampleRate, + 'attachStacktrace': _options.attachStacktrace, + 'maxBreadcrumbs': _options.maxBreadcrumbs, + // using defaultIntegrations ensures that we can control which integrations are added + 'defaultIntegrations': [], + }; + _binding.init(jsOptions); + }); + } + + @override + FutureOr close() { + tryCatchSync('close', () { + _binding.close(); + }); + } + + @override + FutureOr addBreadcrumb(Breadcrumb breadcrumb) { + _logNotSupported('add breadcrumb'); + } + + @override + FutureOr beginNativeFrames() { + _logNotSupported('begin native frames collection'); + } + + @override + FutureOr captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { + _logNotSupported('capture envelope with raw data'); + } + + @override + FutureOr captureEnvelopeObject(SentryEnvelope envelope) async { + final List envelopeItems = []; + + for (final item in envelope.items) { + final originalObject = item.originalObject; + final payload = await originalObject?.getPayload(); + if (payload is Uint8List) { + final length = payload.length; + envelopeItems.add([ + (await item.header.toJson(length)), + payload, + ]); + } else { + final length = + payload != null ? utf8.encode(json.encode(payload)).length : 0; + envelopeItems.add([(await item.header.toJson(length)), payload]); + } + + // We use `sendEnvelope` where sessions are not managed in the JS SDK + // so we have to do it manually + if (originalObject is SentryEvent && + originalObject.exceptions?.isEmpty == false) { + // todo: how to test this except manually checking? + final session = _binding.getSession(); + if (session != null) { + if (envelope.containsUnhandledException) { + session.status = 'crashed'.toJS; + } + session.errors = originalObject.exceptions!.length.toJS; + _binding.captureSession(); + } + } + } + + final jsEnvelope = [envelope.header.toJson(), envelopeItems]; + + _binding.captureEnvelope(jsEnvelope); + } + + String uint8ListToHexString(Uint8List uint8list) { + var hex = ""; + for (var i in uint8list) { + var x = i.toRadixString(16); + if (x.length == 1) { + x = "0$x"; + } + hex += x; + } + return hex; + } + + @override + FutureOr captureReplay(bool isCrash) { + throw UnsupportedError( + "$SentryWeb.captureReplay() not supported on this platform"); + } + + @override + FutureOr clearBreadcrumbs() { + _logNotSupported('clear breadcrumbs'); + } + + @override + FutureOr?> collectProfile( + SentryId traceId, int startTimeNs, int endTimeNs) { + _logNotSupported('collect profile'); + return null; + } + + @override + FutureOr discardProfiler(SentryId traceId) { + _logNotSupported('discard profiler'); + } + + @override + FutureOr displayRefreshRate() { + _logNotSupported('fetching display refresh rate'); + return null; + } + + @override + FutureOr endNativeFrames(SentryId id) { + _logNotSupported('end native frames collection'); + return null; + } + + @override + FutureOr fetchNativeAppStart() { + _logNotSupported('fetch native app start'); + return null; + } + + @override + FutureOr?> loadContexts() { + _logNotSupported('load contexts'); + return null; + } + + @override + FutureOr?> loadDebugImages(SentryStackTrace stackTrace) { + _logNotSupported('loading debug images'); + return null; + } + + @override + FutureOr nativeCrash() { + _logNotSupported('native crash'); + } + + @override + FutureOr removeContexts(String key) { + _logNotSupported('remove contexts'); + } + + @override + FutureOr removeExtra(String key) { + _logNotSupported('remove extra'); + } + + @override + FutureOr removeTag(String key) { + _logNotSupported('remove tag'); + } + + @override + FutureOr resumeAppHangTracking() { + _logNotSupported('resume app hang tracking'); + } + + @override + FutureOr pauseAppHangTracking() { + _logNotSupported('pause app hang tracking'); + } + + @override + FutureOr setContexts(String key, value) { + _logNotSupported('set contexts'); + } + + @override + FutureOr setExtra(String key, value) { + _logNotSupported('set extra'); + } + + @override + FutureOr setReplayConfig(ReplayConfig config) { + _logNotSupported('setting replay config'); + } + + @override + FutureOr setTag(String key, String value) { + _logNotSupported('set tag'); + } + + @override + FutureOr setUser(SentryUser? user) { + _logNotSupported('set user'); + } + + @override + int? startProfiler(SentryId traceId) { + _logNotSupported('start profiler'); + return null; + } + + @override + bool get supportsCaptureEnvelope => true; + + @override + bool get supportsLoadContexts => false; + + @override + bool get supportsReplay => false; + + @override + SentryFlutterOptions get options => _options; +} diff --git a/flutter/lib/src/web/web_sentry_js_binding.dart b/flutter/lib/src/web/web_sentry_js_binding.dart new file mode 100644 index 0000000000..7c92e348ff --- /dev/null +++ b/flutter/lib/src/web/web_sentry_js_binding.dart @@ -0,0 +1,96 @@ +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:meta/meta.dart'; + +import 'sentry_js_binding.dart'; + +SentryJsBinding createJsBinding() { + return WebSentryJsBinding(); +} + +class WebSentryJsBinding implements SentryJsBinding { + @override + void init(Map options) { + SentryJsBridge.init(options.jsify()); + } + + @override + void close() { + final sentryProp = globalThis.getProperty('Sentry'.toJS); + if (sentryProp != null) { + SentryJsBridge.close(); + globalThis['Sentry'] = null; + } + } + + @override + void captureEnvelope(List envelope) { + SentryJsBridge.getClient().sendEnvelope(envelope.jsify()); + } + + @override + void captureSession() { + SentryJsBridge.captureSession(); + } + + @override + getSession() { + return SentryJsBridge.getSession(); + } +} + +@JS('globalThis') +@internal +external JSObject get globalThis; + +@JS('Sentry') +@staticInterop +@internal +class SentryJsBridge { + external static void init(JSAny? options); + + external static void close(); + + external static SentryJsClient getClient(); + + external static void captureSession(); + + external static SentryJsScope? getCurrentScope(); + + external static SentryJsScope? getIsolationScope(); + + static SentryJsSession? getSession() { + return getCurrentScope()?.getSession() ?? getIsolationScope()?.getSession(); + } +} + +@JS('Session') +@staticInterop +@internal +class SentryJsSession {} + +extension SentryJsSessionExtension on SentryJsSession { + external JSString status; + + external JSNumber errors; +} + +@JS('Scope') +@staticInterop +class SentryJsScope {} + +extension SentryScopeExtension on SentryJsScope { + external SentryJsSession? getSession(); +} + +@JS('Client') +@staticInterop +@internal +class SentryJsClient {} + +extension SentryJsClientExtension on SentryJsClient { + external JSAny? sendEnvelope(JSAny? envelope); + + external JSFunction on(JSString hook, JSFunction callback); +} diff --git a/flutter/test/file_system_transport_test.dart b/flutter/test/file_system_transport_test.dart index 91340f7fb6..43099bc4a3 100644 --- a/flutter/test/file_system_transport_test.dart +++ b/flutter/test/file_system_transport_test.dart @@ -2,7 +2,6 @@ library flutter_test; import 'dart:convert'; - // backcompatibility for Flutter < 3.3 // ignore: unnecessary_import import 'dart:typed_data'; @@ -11,7 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; -import 'package:sentry_flutter/src/file_system_transport.dart'; +import 'package:sentry_flutter/src/transport/file_system_transport.dart'; import 'mocks.dart'; import 'mocks.mocks.dart'; diff --git a/flutter/test/integrations/web_sdk_integration_test.dart b/flutter/test/integrations/web_sdk_integration_test.dart index 9d0c4da6f7..47e385e866 100644 --- a/flutter/test/integrations/web_sdk_integration_test.dart +++ b/flutter/test/integrations/web_sdk_integration_test.dart @@ -2,6 +2,7 @@ library flutter_test; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/src/integrations/web_sdk_integration.dart'; import 'package:sentry_flutter/src/web/script_loader/sentry_script_loader.dart'; @@ -11,45 +12,109 @@ import '../mocks.mocks.dart'; void main() { group('$WebSdkIntegration', () { late Fixture fixture; + late WebSdkIntegration sut; setUp(() async { fixture = Fixture(); + sut = fixture.getSut(); + + when(fixture.web.init(any)).thenReturn(null); + when(fixture.web.close()).thenReturn(null); }); - test('adds integration', () async { - final sut = fixture.getSut(); + group('enabled', () { + setUp(() { + fixture.options.enableSentryJs = true; + fixture.options.autoInitializeNativeSdk = true; + }); + + test('adds integration', () async { + await sut.call(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations.contains(WebSdkIntegration.name), + true); + }); - await sut.call(fixture.hub, fixture.options); + test('loads scripts and initializes web', () async { + await sut.call(fixture.hub, fixture.options); - expect(fixture.options.sdk.integrations.contains(WebSdkIntegration.name), - true); + expect(fixture.scriptLoader.loadScriptsCalls, 1); + verify(fixture.web.init(fixture.hub)).called(1); + }); }); - test('calls executes loads scripts', () async { - final sut = fixture.getSut(); + group('disabled scenarios', () { + final disabledScenarios = [ + _TestScenario( + 'with autoInitializeNativeSdk=false', + () { + fixture.options.enableSentryJs = true; + fixture.options.autoInitializeNativeSdk = false; + }, + ), + _TestScenario( + 'with enableSentryJs=false', + () { + fixture.options.enableSentryJs = false; + fixture.options.autoInitializeNativeSdk = true; + }, + ), + ]; + + for (final scenario in disabledScenarios) { + group(scenario.description, () { + setUp(scenario.setup); + + test('does not add integration', () async { + await sut.call(fixture.hub, fixture.options); + expect(fixture.options.sdk.integrations, + isNot(contains(WebSdkIntegration.name))); + }); + + test('does not load scripts and initialize web', () async { + await sut.call(fixture.hub, fixture.options); + expect(fixture.scriptLoader.loadScriptsCalls, 0); + verifyNever(fixture.web.init(fixture.hub)); + }); + }); + } + }); - await sut.call(fixture.hub, fixture.options); + test('closes resources', () async { + await sut.close(); - expect(fixture.scriptLoader.loadScriptsCalls, 1); + expect(fixture.scriptLoader.closeCalls, 1); + verify(fixture.web.close()).called(1); }); }); } +class _TestScenario { + final String description; + final dynamic Function() setup; + + _TestScenario(this.description, this.setup); +} + class Fixture { final hub = MockHub(); final options = defaultTestOptions(); late FakeSentryScriptLoader scriptLoader; + late MockSentryNativeBinding web; WebSdkIntegration getSut() { - scriptLoader = FakeSentryScriptLoader(options); - return WebSdkIntegration(scriptLoader); + scriptLoader = FakeSentryScriptLoader(options: options); + web = MockSentryNativeBinding(); + return WebSdkIntegration(web, scriptLoader); } } class FakeSentryScriptLoader extends SentryScriptLoader { - FakeSentryScriptLoader(super.options); + FakeSentryScriptLoader({super.options}); int loadScriptsCalls = 0; + int closeCalls = 0; @override Future loadWebSdk(List> scripts, @@ -59,4 +124,11 @@ class FakeSentryScriptLoader extends SentryScriptLoader { return super .loadWebSdk(scripts, trustedTypePolicyName: trustedTypePolicyName); } + + @override + Future close() { + closeCalls += 1; + + return super.close(); + } } diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index 66e05b68eb..d026fe9440 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -3,16 +3,16 @@ import 'package:flutter/services.dart'; import 'package:flutter/src/widgets/binding.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:meta/meta.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/sentry_tracer.dart'; - -import 'package:meta/meta.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/frames_tracking/sentry_delayed_frames_tracker.dart'; -import 'package:sentry_flutter/src/renderer/renderer.dart'; import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; +import 'package:sentry_flutter/src/renderer/renderer.dart'; +import 'package:sentry_flutter/src/web/sentry_js_binding.dart'; import 'mocks.mocks.dart'; import 'no_such_method_provider.dart'; @@ -53,6 +53,7 @@ ISentrySpan startTransactionShim( SentryDelayedFramesTracker, BindingWrapper, WidgetsFlutterBinding, + SentryJsBinding, ], customMocks: [ MockSpec(fallbackGenerators: {#startTransaction: startTransactionShim}) ]) diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index 47dece300e..6e3bf62f6b 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -28,6 +28,7 @@ import 'package:sentry_flutter/src/frames_tracking/sentry_delayed_frames_tracker import 'package:sentry_flutter/src/native/native_frames.dart' as _i20; import 'package:sentry_flutter/src/native/sentry_native_binding.dart' as _i18; import 'package:sentry_flutter/src/replay/replay_config.dart' as _i21; +import 'package:sentry_flutter/src/web/sentry_js_binding.dart' as _i25; import 'mocks.dart' as _i14; @@ -3462,6 +3463,33 @@ class MockWidgetsFlutterBinding extends _i1.Mock )) as _i6.Locale?); } +/// A class which mocks [SentryJsBinding]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSentryJsBinding extends _i1.Mock implements _i25.SentryJsBinding { + MockSentryJsBinding() { + _i1.throwOnMissingStub(this); + } + + @override + void init(Map? options) => super.noSuchMethod( + Invocation.method( + #init, + [options], + ), + returnValueForMissingStub: null, + ); + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); +} + /// A class which mocks [Hub]. /// /// See the documentation for Mockito's code generation for more information. diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index 5a41066c6f..5614a7b3ca 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -1,12 +1,12 @@ // ignore_for_file: invalid_use_of_internal_member +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/dart_exception_type_identifier.dart'; +import 'package:sentry/src/platform/platform.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/file_system_transport.dart'; import 'package:sentry_flutter/src/flutter_exception_type_identifier.dart'; import 'package:sentry_flutter/src/integrations/connectivity/connectivity_integration.dart'; import 'package:sentry_flutter/src/integrations/integrations.dart'; @@ -14,8 +14,10 @@ import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; import 'package:sentry_flutter/src/profiling.dart'; import 'package:sentry_flutter/src/renderer/renderer.dart'; import 'package:sentry_flutter/src/replay/integration.dart'; +import 'package:sentry_flutter/src/transport/file_system_transport.dart'; import 'package:sentry_flutter/src/version.dart'; import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_integration.dart'; + import 'mocks.dart'; import 'mocks.mocks.dart'; import 'sentry_flutter_util.dart'; @@ -50,11 +52,6 @@ final iOsAndMacOsIntegrations = [ LoadContextsIntegration, ]; -// These should be added to every platform which has a native integration. -final nativeIntegrations = [ - NativeSdkIntegration, -]; - void main() { TestWidgetsFlutterBinding.ensureInitialized(); late NativeChannelFixture native; @@ -99,7 +96,6 @@ void main() { integrations: options.integrations, shouldHaveIntegrations: [ ...androidIntegrations, - ...nativeIntegrations, ...platformAgnosticIntegrations, ...nonWebIntegrations, ReplayIntegration, @@ -157,7 +153,6 @@ void main() { integrations: options.integrations, shouldHaveIntegrations: [ ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...platformAgnosticIntegrations, ...nonWebIntegrations, ReplayIntegration, @@ -210,7 +205,6 @@ void main() { testConfiguration(integrations: integrations, shouldHaveIntegrations: [ ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...platformAgnosticIntegrations, ...nonWebIntegrations, ], shouldNotHaveIntegrations: [ @@ -263,7 +257,6 @@ void main() { shouldNotHaveIntegrations: [ ...androidIntegrations, ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...webIntegrations, ], ); @@ -311,7 +304,6 @@ void main() { shouldNotHaveIntegrations: [ ...androidIntegrations, ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...webIntegrations, ], ); @@ -330,9 +322,9 @@ void main() { test('Web', () async { List integrations = []; Transport transport = MockTransport(); - final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(isWeb: true, platform: MockPlatform.linux())) - ..methodChannel = native.channel; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.linux())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) async { @@ -360,7 +352,6 @@ void main() { shouldNotHaveIntegrations: [ ...androidIntegrations, ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...nonWebIntegrations, ], ); @@ -370,15 +361,15 @@ void main() { beforeIntegration: RunZonedGuardedIntegration, afterIntegration: WidgetsFlutterBindingIntegration); - expect(SentryFlutter.native, isNull); + expect(SentryFlutter.native, isNotNull); expect(Sentry.currentHub.profilerFactory, isNull); await Sentry.close(); - }); + }, testOn: 'browser'); test('Web (custom zone)', () async { - final sentryFlutterOptions = defaultTestOptions(getPlatformChecker( - platform: MockPlatform.linux(), isWeb: true, isRootZone: false)) + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.linux(), isRootZone: false)) ..methodChannel = native.channel; await SentryFlutter.init( @@ -395,15 +386,17 @@ void main() { ); expect(containsRunZonedGuardedIntegration, isFalse); + expect(SentryFlutter.native, isNotNull); + await Sentry.close(); - }); + }, testOn: 'browser'); test('Web && (iOS || macOS)', () async { List integrations = []; Transport transport = MockTransport(); - final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(isWeb: true, platform: MockPlatform.iOs())) - ..methodChannel = native.channel; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.iOs())) + ..methodChannel = native.channel; // Tests that iOS || macOS integrations aren't added on a browser which // runs on iOS or macOS @@ -427,7 +420,6 @@ void main() { shouldNotHaveIntegrations: [ ...androidIntegrations, ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...nonWebIntegrations, ], ); @@ -437,15 +429,17 @@ void main() { beforeIntegration: RunZonedGuardedIntegration, afterIntegration: WidgetsFlutterBindingIntegration); + expect(SentryFlutter.native, isNotNull); + await Sentry.close(); - }); + }, testOn: 'browser'); test('Web && (macOS)', () async { List integrations = []; Transport transport = MockTransport(); - final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(isWeb: true, platform: MockPlatform.macOs())) - ..methodChannel = native.channel; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.macOs())) + ..methodChannel = native.channel; // Tests that iOS || macOS integrations aren't added on a browser which // runs on iOS or macOS @@ -469,7 +463,6 @@ void main() { shouldNotHaveIntegrations: [ ...androidIntegrations, ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...nonWebIntegrations, ], ); @@ -480,15 +473,16 @@ void main() { afterIntegration: WidgetsFlutterBindingIntegration); expect(Sentry.currentHub.profilerFactory, isNull); + expect(SentryFlutter.native, isNotNull); await Sentry.close(); - }); + }, testOn: 'browser'); test('Web && Android', () async { List integrations = []; Transport transport = MockTransport(); final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(isWeb: true, platform: MockPlatform.android())) + getPlatformChecker(platform: MockPlatform.android())) ..methodChannel = native.channel; // Tests that Android integrations aren't added on an Android browser @@ -512,7 +506,6 @@ void main() { shouldNotHaveIntegrations: [ ...androidIntegrations, ...iOsAndMacOsIntegrations, - ...nativeIntegrations, ...nonWebIntegrations, ], ); @@ -522,8 +515,10 @@ void main() { beforeIntegration: RunZonedGuardedIntegration, afterIntegration: WidgetsFlutterBindingIntegration); + expect(SentryFlutter.native, isNotNull); + await Sentry.close(); - }); + }, testOn: 'browser'); }); group('Test ScreenshotIntegration', () { @@ -534,12 +529,12 @@ void main() { test('installed on io platforms', () async { List integrations = []; - final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(platform: MockPlatform.iOs(), isWeb: false)) - ..methodChannel = native.channel - ..rendererWrapper = MockRendererWrapper(FlutterRenderer.skia) - ..release = '' - ..dist = ''; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.iOs())) + ..methodChannel = native.channel + ..rendererWrapper = MockRendererWrapper(FlutterRenderer.skia) + ..release = '' + ..dist = ''; await SentryFlutter.init( (options) async { @@ -561,11 +556,11 @@ void main() { test('installed with canvasKit renderer', () async { List integrations = []; - final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(platform: MockPlatform.iOs(), isWeb: true)) - ..rendererWrapper = MockRendererWrapper(FlutterRenderer.canvasKit) - ..release = '' - ..dist = ''; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.iOs())) + ..rendererWrapper = MockRendererWrapper(FlutterRenderer.canvasKit) + ..release = '' + ..dist = ''; await SentryFlutter.init( (options) async { @@ -582,16 +577,16 @@ void main() { true); await Sentry.close(); - }, testOn: 'vm'); + }, testOn: 'browser'); test('not installed with html renderer', () async { List integrations = []; - final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(platform: MockPlatform.iOs(), isWeb: true)) - ..rendererWrapper = MockRendererWrapper(FlutterRenderer.html) - ..release = '' - ..dist = ''; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.iOs())) + ..rendererWrapper = MockRendererWrapper(FlutterRenderer.html) + ..release = '' + ..dist = ''; await SentryFlutter.init( (options) async { @@ -608,18 +603,22 @@ void main() { false); await Sentry.close(); - }, testOn: 'vm'); + }, testOn: 'browser'); }); group('initial values', () { setUp(() async { loadTestPackage(); + }); + + tearDown(() async { await Sentry.close(); }); test('test that initial values are set correctly', () async { final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(platform: MockPlatform.android(), isWeb: true)); + getPlatformChecker(platform: MockPlatform.android())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) { @@ -633,15 +632,15 @@ void main() { appRunner: appRunner, options: sentryFlutterOptions, ); - - await Sentry.close(); }); test( 'enablePureDartSymbolication is set to false during SentryFlutter init', () async { final sentryFlutterOptions = defaultTestOptions( - getPlatformChecker(platform: MockPlatform.android(), isWeb: true)); + getPlatformChecker(platform: MockPlatform.android())) + ..methodChannel = native.channel; + SentryFlutter.native = mockNativeBinding(); await SentryFlutter.init( (options) { @@ -650,8 +649,6 @@ void main() { appRunner: appRunner, options: sentryFlutterOptions, ); - - await Sentry.close(); }); }); @@ -696,6 +693,9 @@ void main() { group('exception identifiers', () { setUp(() async { loadTestPackage(); + }); + + tearDown(() async { await Sentry.close(); }); @@ -703,7 +703,9 @@ void main() { 'should add DartExceptionTypeIdentifier and FlutterExceptionTypeIdentifier by default', () async { final actualOptions = defaultTestOptions( - getPlatformChecker(platform: MockPlatform.android(), isWeb: true)); + getPlatformChecker(platform: MockPlatform.android())) + ..methodChannel = native.channel; + await SentryFlutter.init( (options) {}, appRunner: appRunner, @@ -728,8 +730,6 @@ void main() { isA(), ), ); - - await Sentry.close(); }); }); } @@ -760,7 +760,7 @@ void loadTestPackage() { PlatformChecker getPlatformChecker({ required Platform platform, - bool isWeb = false, + bool isWeb = kIsWeb, bool isRootZone = true, }) { final platformChecker = PlatformChecker( diff --git a/flutter/test/sentry_flutter_util.dart b/flutter/test/sentry_flutter_util.dart index 30e195965b..76c0a8a2a0 100644 --- a/flutter/test/sentry_flutter_util.dart +++ b/flutter/test/sentry_flutter_util.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/native/native_scope_observer.dart'; @@ -19,6 +21,7 @@ void testConfiguration({ required Iterable integrations, required Iterable shouldHaveIntegrations, required Iterable shouldNotHaveIntegrations, + SentryFlutterOptions? options, }) { final numberOfIntegrationsByType = {}; for (var e in integrations) { @@ -35,6 +38,16 @@ void testConfiguration({ for (final type in shouldNotHaveIntegrations) { expect(integrations, isNot(contains(type))); } + + Integration? nativeIntegration; + if (kIsWeb) { + nativeIntegration = integrations.firstWhereOrNull( + (x) => x.runtimeType.toString() == 'WebSdkIntegration'); + } else { + nativeIntegration = integrations.firstWhereOrNull( + (x) => x.runtimeType.toString() == 'NativeSdkIntegration'); + } + expect(nativeIntegration, isNotNull); } void testBefore({ diff --git a/flutter/test/sentry_navigator_observer_test.dart b/flutter/test/sentry_navigator_observer_test.dart index 62e53ff705..25c046343a 100644 --- a/flutter/test/sentry_navigator_observer_test.dart +++ b/flutter/test/sentry_navigator_observer_test.dart @@ -77,7 +77,7 @@ void main() { // Handle internal async method calls. await Future.delayed(const Duration(milliseconds: 10), () {}); verify(mockBinding.beginNativeFrames()).called(1); - }); + }, testOn: 'vm'); test('transaction finish adds native frames to tracer', () async { final currentRoute = route(RouteSettings(name: 'Current Route')); @@ -125,7 +125,7 @@ void main() { expect(measurement.value, expectedFrozen.value); } } - }); + }, testOn: 'vm'); }); group('$SentryNavigatorObserver', () { diff --git a/flutter/test/web/dom_api/html_script_dom_api.dart b/flutter/test/web/dom_api/html_script_dom_api.dart deleted file mode 100644 index 55a1ac6bd5..0000000000 --- a/flutter/test/web/dom_api/html_script_dom_api.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:html'; - -import 'script_dom_api.dart'; - -class HtmlScriptElement implements TestScriptElement { - final ScriptElement element; - - HtmlScriptElement(this.element); - - @override - void remove() { - element.remove(); - } - - @override - String get src => element.src; -} - -List fetchAllScripts() { - final scripts = document.querySelectorAll('script'); - return scripts - .map((script) => HtmlScriptElement(script as ScriptElement)) - .toList(); -} - -void injectMetaTag(Map attributes) { - final MetaElement meta = document.createElement('meta') as MetaElement; - for (final MapEntry attribute in attributes.entries) { - meta.setAttribute(attribute.key, attribute.value); - } - document.head!.append(meta); -} diff --git a/flutter/test/web/dom_api/noop_script_dom_api.dart b/flutter/test/web/dom_api/noop_script_dom_api.dart deleted file mode 100644 index bdcd1ea66a..0000000000 --- a/flutter/test/web/dom_api/noop_script_dom_api.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'script_dom_api.dart'; - -List fetchAllScripts() => []; - -void injectMetaTag(Map attributes) {} diff --git a/flutter/test/web/dom_api/script_dom_api.dart b/flutter/test/web/dom_api/script_dom_api.dart deleted file mode 100644 index e3369d16d9..0000000000 --- a/flutter/test/web/dom_api/script_dom_api.dart +++ /dev/null @@ -1,8 +0,0 @@ -export 'noop_script_dom_api.dart' - if (dart.library.html) 'html_script_dom_api.dart' - if (dart.library.js_interop) 'web_script_dom_api.dart'; - -abstract class TestScriptElement { - String get src; - void remove(); -} diff --git a/flutter/test/web/dom_api/web_script_dom_api.dart b/flutter/test/web/dom_api/web_script_dom_api.dart deleted file mode 100644 index 287aaa4a6a..0000000000 --- a/flutter/test/web/dom_api/web_script_dom_api.dart +++ /dev/null @@ -1,39 +0,0 @@ -// ignore: depend_on_referenced_packages -import 'package:web/web.dart'; - -import 'script_dom_api.dart'; - -class _ScriptElement implements TestScriptElement { - final HTMLScriptElement element; - - _ScriptElement(this.element); - - @override - void remove() { - element.remove(); - } - - @override - String get src => element.src; -} - -List fetchAllScripts() { - final scripts = document.querySelectorAll('script'); - - List elements = []; - for (int i = 0; i < scripts.length; i++) { - final node = scripts.item(i); - elements.add(_ScriptElement(node as HTMLScriptElement)); - } - - return elements; -} - -void injectMetaTag(Map attributes) { - final HTMLMetaElement meta = - document.createElement('meta') as HTMLMetaElement; - for (final MapEntry attribute in attributes.entries) { - meta.setAttribute(attribute.key, attribute.value); - } - document.head!.appendChild(meta); -} diff --git a/flutter/test/web/html_utils.dart b/flutter/test/web/html_utils.dart new file mode 100644 index 0000000000..304f2ea7df --- /dev/null +++ b/flutter/test/web/html_utils.dart @@ -0,0 +1,15 @@ +import 'dart:html'; +import 'dart:js'; + +void injectMetaTag(Map attributes) { + final MetaElement meta = document.createElement('meta') as MetaElement; + for (final MapEntry attribute in attributes.entries) { + meta.setAttribute(attribute.key, attribute.value); + } + document.head!.append(meta); +} + +dynamic getJsOptions() { + final sentry = context['Sentry'] as JsObject; + return sentry.callMethod('getClient').callMethod('getOptions'); +} diff --git a/flutter/test/web/sentry_script_loader_test.dart b/flutter/test/web/sentry_script_loader_test.dart index 0d2609d244..ca17bede26 100644 --- a/flutter/test/web/sentry_script_loader_test.dart +++ b/flutter/test/web/sentry_script_loader_test.dart @@ -2,12 +2,16 @@ library flutter_test; import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/src/web/script_loader/noop_script_dom_api.dart'; +import 'package:sentry_flutter/src/web/script_loader/script_dom_api.dart'; import 'package:sentry_flutter/src/web/script_loader/sentry_script_loader.dart'; import 'package:sentry_flutter/src/web/sentry_js_bundle.dart'; import '../mocks.dart'; -import 'dom_api/script_dom_api.dart'; +import 'utils.dart'; + +// Just some random/arbitrary script that we can use for injecting +const randomWorkingScriptUrl = + 'https://cdn.jsdelivr.net/npm/random-js@2.1.0/dist/random-js.umd.min.js'; void main() { group('$SentryScriptLoader', () { @@ -15,9 +19,7 @@ void main() { setUp(() { fixture = Fixture(); - }); - tearDown(() { final existingScripts = fetchAllScripts(); for (final script in existingScripts) { script.remove(); @@ -100,13 +102,30 @@ void main() { final sut = fixture.getSut(); // use loadScript since that disregards the isLoaded check - await loadScript('https://google.com', fixture.options); + await loadScript(randomWorkingScriptUrl, fixture.options); await sut.loadWebSdk(productionScripts); final scriptElements = fetchAllScripts(); expect(scriptElements.first.src, endsWith('$jsSdkVersion/bundle.tracing.min.js')); }); + + test('Closes and cleans up resources', () async { + final sut = fixture.getSut(); + + await loadScript(randomWorkingScriptUrl, fixture.options); + + await sut.loadWebSdk(debugScripts); + + final beforeCloseScripts = fetchAllScripts(); + expect(beforeCloseScripts.length, 2); + + await sut.close(); + + final afterCloseScripts = fetchAllScripts(); + expect(afterCloseScripts.length, + beforeCloseScripts.length - debugScripts.length); + }); }); } @@ -114,6 +133,6 @@ class Fixture { final options = defaultTestOptions(); SentryScriptLoader getSut() { - return SentryScriptLoader(options); + return SentryScriptLoader(options: options); } } diff --git a/flutter/test/web/sentry_script_loader_tt_custom_test.dart b/flutter/test/web/sentry_script_loader_tt_custom_test.dart index 8eab13c2e5..19f8601b14 100644 --- a/flutter/test/web/sentry_script_loader_tt_custom_test.dart +++ b/flutter/test/web/sentry_script_loader_tt_custom_test.dart @@ -6,7 +6,7 @@ import 'package:sentry_flutter/src/web/script_loader/sentry_script_loader.dart'; import 'package:sentry_flutter/src/web/sentry_js_bundle.dart'; import '../mocks.dart'; -import 'dom_api/script_dom_api.dart'; +import 'utils.dart'; // The other TT tests will be split up into multiple files // because TrustedTypes cannot be relaxed after they are set @@ -77,6 +77,6 @@ class Fixture { final options = defaultTestOptions(); SentryScriptLoader getSut() { - return SentryScriptLoader(options); + return SentryScriptLoader(options: options); } } diff --git a/flutter/test/web/sentry_script_loader_tt_forbidden_test.dart b/flutter/test/web/sentry_script_loader_tt_forbidden_test.dart index 47c11d158e..29123caad2 100644 --- a/flutter/test/web/sentry_script_loader_tt_forbidden_test.dart +++ b/flutter/test/web/sentry_script_loader_tt_forbidden_test.dart @@ -6,7 +6,7 @@ import 'package:sentry_flutter/src/web/script_loader/sentry_script_loader.dart'; import 'package:sentry_flutter/src/web/sentry_js_bundle.dart'; import '../mocks.dart'; -import 'dom_api/script_dom_api.dart'; +import 'utils.dart'; // The other TT tests will be split up into multiple files // because TrustedTypes cannot be relaxed after they are set @@ -56,6 +56,6 @@ class Fixture { final options = defaultTestOptions(); SentryScriptLoader getSut() { - return SentryScriptLoader(options); + return SentryScriptLoader(options: options); } } diff --git a/flutter/test/web/sentry_web_test.dart b/flutter/test/web/sentry_web_test.dart new file mode 100644 index 0000000000..31f173db31 --- /dev/null +++ b/flutter/test/web/sentry_web_test.dart @@ -0,0 +1,142 @@ +@TestOn('browser') +library flutter_test; + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/replay/replay_config.dart'; +import 'package:sentry_flutter/src/web/script_loader/sentry_script_loader.dart'; +import 'package:sentry_flutter/src/web/sentry_js_binding.dart'; +import 'package:sentry_flutter/src/web/sentry_js_bundle.dart'; +import 'package:sentry_flutter/src/web/sentry_web.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; +import 'utils.dart'; + +void main() { + group('$SentryWeb', () { + late SentryFlutterOptions options; + late Hub hub; + + setUp(() { + hub = MockHub(); + options = defaultTestOptions(); + }); + + group('with real binding', () { + late SentryWeb sut; + + setUp(() async { + final loader = SentryScriptLoader(options: options); + await loader.loadWebSdk(debugScripts); + final binding = createJsBinding(); + sut = SentryWeb(binding, options); + }); + + tearDown(() async { + await sut.close(); + }); + + test('init: options mapped to JS SDK', () async { + const expectedDsn = 'https://random@def.ingest.sentry.io/1234567'; + const expectedRelease = 'my-random-release'; + const expectedSampleRate = 0.2; + const expectedEnv = 'my-random-env'; + const expectedDist = '999'; + const expectedAttachStacktrace = false; + const expectedMaxBreadcrumbs = 1000; + const expectedDebug = true; + + options.dsn = expectedDsn; + options.release = expectedRelease; + options.sampleRate = expectedSampleRate; + options.environment = expectedEnv; + options.dist = expectedDist; + options.attachStacktrace = expectedAttachStacktrace; + options.maxBreadcrumbs = expectedMaxBreadcrumbs; + options.debug = expectedDebug; + + // quick check that it doesn't work before init + expect(() => getJsOptions()['dsn'], throwsA(anything)); + + await sut.init(hub); + + final jsOptions = getJsOptions(); + expect(jsOptions['dsn'], expectedDsn); + expect(jsOptions['release'], expectedRelease); + expect(jsOptions['sampleRate'], expectedSampleRate); + expect(jsOptions['environment'], expectedEnv); + expect(jsOptions['dist'], expectedDist); + expect(jsOptions['attachStacktrace'], expectedAttachStacktrace); + expect(jsOptions['maxBreadcrumbs'], expectedMaxBreadcrumbs); + expect(jsOptions['debug'], expectedDebug); + }); + + test('options getter returns the original options', () { + expect(sut.options, same(options)); + }); + + test('native features are not supported', () { + expect(sut.supportsCaptureEnvelope, isFalse); + expect(sut.supportsLoadContexts, isFalse); + expect(sut.supportsReplay, isFalse); + }); + }); + + group('no-op or throwing methods', () { + late MockSentryJsBinding mockBinding; + late SentryWeb sut; + + setUp(() { + mockBinding = MockSentryJsBinding(); + sut = SentryWeb(mockBinding, options); + }); + + test('captureReplay throws unsupported error', () { + expect(() => sut.captureReplay(false), throwsUnsupportedError); + }); + + test('methods execute without calling JS binding', () { + sut.addBreadcrumb(Breadcrumb()); + sut.beginNativeFrames(); + sut.captureEnvelope(Uint8List(0), false); + sut.clearBreadcrumbs(); + sut.collectProfile(SentryId.empty(), 0, 0); + sut.discardProfiler(SentryId.empty()); + sut.displayRefreshRate(); + sut.endNativeFrames(SentryId.empty()); + sut.fetchNativeAppStart(); + sut.loadContexts(); + sut.loadDebugImages(SentryStackTrace(frames: [])); + sut.nativeCrash(); + sut.removeContexts('key'); + sut.removeExtra('key'); + sut.removeTag('key'); + sut.resumeAppHangTracking(); + sut.pauseAppHangTracking(); + sut.setContexts('key', 'value'); + sut.setExtra('key', 'value'); + sut.setReplayConfig( + ReplayConfig(width: 0, height: 0, frameRate: 0, bitRate: 0)); + sut.setTag('key', 'value'); + sut.setUser(null); + sut.startProfiler(SentryId.empty()); + + verifyZeroInteractions(mockBinding); + }); + + test('methods return expected default values', () { + expect(sut.displayRefreshRate(), isNull); + expect(sut.fetchNativeAppStart(), isNull); + expect(sut.loadContexts(), isNull); + expect(sut.loadDebugImages(SentryStackTrace(frames: [])), isNull); + expect(sut.collectProfile(SentryId.empty(), 0, 0), isNull); + expect(sut.endNativeFrames(SentryId.empty()), isNull); + expect(sut.startProfiler(SentryId.empty()), isNull); + }); + }); + }); +} diff --git a/flutter/test/web/utils.dart b/flutter/test/web/utils.dart new file mode 100644 index 0000000000..81fc6af0c7 --- /dev/null +++ b/flutter/test/web/utils.dart @@ -0,0 +1,7 @@ +import 'package:sentry_flutter/src/web/script_loader/script_dom_api.dart'; + +export 'html_utils.dart' if (dart.library.js_interop) 'web_utils.dart'; + +List fetchAllScripts() { + return fetchScripts('script'); +} diff --git a/flutter/test/web/web_utils.dart b/flutter/test/web/web_utils.dart new file mode 100644 index 0000000000..53ec33f6b3 --- /dev/null +++ b/flutter/test/web/web_utils.dart @@ -0,0 +1,26 @@ +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +// ignore: depend_on_referenced_packages +import 'package:web/web.dart'; + +void injectMetaTag(Map attributes) { + final HTMLMetaElement meta = + document.createElement('meta') as HTMLMetaElement; + for (final MapEntry attribute in attributes.entries) { + meta.setAttribute(attribute.key, attribute.value); + } + document.head!.appendChild(meta); +} + +@JS('Sentry') +external JSObject? get sentry; + +dynamic getJsOptions() { + final client = sentry?.callMethod('getClient'.toJS, null) as JSObject?; + if (client == null) { + return null; + } + final options = client.callMethod('getOptions'.toJS, null); + return options?.dartify(); +} diff --git a/scripts/publish_validation/bin/publish_validation.dart b/scripts/publish_validation/bin/publish_validation.dart index 4e63077a9f..fd8a93aef8 100644 --- a/scripts/publish_validation/bin/publish_validation.dart +++ b/scripts/publish_validation/bin/publish_validation.dart @@ -37,7 +37,8 @@ void main(List arguments) async { 'lib/src/platform/_web_platform.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', 'lib/src/event_processor/url_filter/web_url_filter_event_processor.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', 'lib/src/web/script_loader/web_script_dom_api.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', - 'test/web/dom_api/web_script_dom_api.dart: This package does not have web in the `dependencies` or `dev_dependencies` section of `pubspec.yaml`' + 'test/web/dom_api/web_script_dom_api.dart: This package does not have web in the `dependencies` or `dev_dependencies` section of `pubspec.yaml`', + 'test/web/web_utils.dart: This package does not have web in the `dependencies` or `dev_dependencies` section of `pubspec.yaml`', ]; // So far the expected errors all start with `* line`