From 0aaa46e1517a02ba971b15ecbe7960f6045279a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Andra=C5=A1ec?= Date: Tue, 18 Jul 2023 11:26:38 +0000 Subject: [PATCH] Add e2e test for iOS/Android (#1516) Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> --- .../integration_test/integration_test.dart | 123 ++++++++++++++++-- flutter/example/lib/main.dart | 67 +++++++++- flutter/example/pubspec.yaml | 1 + 3 files changed, 179 insertions(+), 12 deletions(-) diff --git a/flutter/example/integration_test/integration_test.dart b/flutter/example/integration_test/integration_test.dart index 3b8462ad44..4ffc5abbfb 100644 --- a/flutter/example/integration_test/integration_test.dart +++ b/flutter/example/integration_test/integration_test.dart @@ -1,9 +1,18 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter_example/main.dart'; +import 'package:http/http.dart'; void main() { + const org = 'sentry-sdks'; + const slug = 'sentry-flutter'; + const authToken = String.fromEnvironment('SENTRY_AUTH_TOKEN'); + const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; + TestWidgetsFlutterBinding.ensureInitialized(); tearDown(() async { @@ -11,14 +20,20 @@ void main() { }); // Using fake DSN for testing purposes. - Future setupSentryAndApp(WidgetTester tester) async { - await setupSentry(() async { - await tester.pumpWidget(SentryScreenshotWidget( - child: DefaultAssetBundle( - bundle: SentryAssetBundle(enableStructuredDataTracing: true), - child: const MyApp(), - ))); - }, 'https://abc@def.ingest.sentry.io/1234567'); + Future setupSentryAndApp(WidgetTester tester, + {String? dsn, BeforeSendCallback? beforeSendCallback}) async { + await setupSentry( + () async { + await tester.pumpWidget(SentryScreenshotWidget( + child: DefaultAssetBundle( + bundle: SentryAssetBundle(enableStructuredDataTracing: true), + child: const MyApp(), + ))); + }, + dsn ?? fakeDsn, + isIntegrationTest: true, + beforeSendCallback: beforeSendCallback, + ); } // Tests @@ -123,4 +138,96 @@ void main() { final transaction = Sentry.startTransactionWithContext(context); await transaction.finish(); }); + + group('e2e', () { + var output = find.byKey(const Key('output')); + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + testWidgets('captureException', (tester) async { + await setupSentryAndApp(tester, + dsn: exampleDsn, beforeSendCallback: fixture.beforeSend); + + await tester.tap(find.text('captureException')); + await tester.pumpAndSettle(); + + final text = output.evaluate().single.widget as Text; + final id = text.data!; + + final uri = Uri.parse( + 'https://sentry.io/api/0/projects/$org/$slug/events/$id/', + ); + + final event = await fixture.poll(uri, authToken); + expect(event, isNotNull); + + final sentEvent = fixture.sentEvent; + expect(sentEvent, isNotNull); + + final tags = event!["tags"] as List; + + expect(sentEvent!.eventId.toString(), event["id"]); + expect("_Exception: Exception: captureException", event["title"]); + expect(sentEvent.release, event["release"]["version"]); + expect( + 2, + (tags.firstWhere((e) => e["value"] == sentEvent.environment) as Map) + .length); + expect(sentEvent.fingerprint, event["fingerprint"] ?? []); + expect( + 2, + (tags.firstWhere((e) => e["value"] == SentryLevel.error.name) as Map) + .length); + expect(sentEvent.logger, event["logger"]); + + final dist = tags.firstWhere((element) => element['key'] == 'dist'); + expect('1', dist['value']); + + final environment = + tags.firstWhere((element) => element['key'] == 'environment'); + expect('integration', environment['value']); + }); + }); +} + +class Fixture { + SentryEvent? sentEvent; + + FutureOr beforeSend(SentryEvent event, {Hint? hint}) async { + sentEvent = event; + return event; + } + + Future?> poll(Uri url, String authToken) async { + final client = Client(); + + const maxRetries = 10; + const initialDelay = Duration(seconds: 2); + const factor = 2; + + var retries = 0; + var delay = initialDelay; + + while (retries < maxRetries) { + try { + final response = await client.get( + url, + headers: {'Authorization': 'Bearer $authToken'}, + ); + if (response.statusCode == 200) { + return jsonDecode(utf8.decode(response.bodyBytes)); + } + } catch (e) { + // Do nothing + } finally { + retries++; + await Future.delayed(delay); + delay *= factor; + } + } + return null; + } } diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 4c3d7c5938..9d02d1ecba 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -21,10 +22,11 @@ import 'package:sentry_dio/sentry_dio.dart'; import 'package:sentry_logging/sentry_logging.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io -const String _exampleDsn = +const String exampleDsn = 'https://e85b375ffb9f43cf8bdf9787768149e0@o447951.ingest.sentry.io/5428562'; const _channel = MethodChannel('example.flutter.sentry.io'); +var _isIntegrationTest = false; Future main() async { await setupSentry( @@ -38,12 +40,14 @@ Future main() async { ), ), ), - _exampleDsn); + exampleDsn); } -Future setupSentry(AppRunner appRunner, String dsn) async { +Future setupSentry(AppRunner appRunner, String dsn, + {bool isIntegrationTest = false, + BeforeSendCallback? beforeSendCallback}) async { await SentryFlutter.init((options) { - options.dsn = _exampleDsn; + options.dsn = exampleDsn; options.tracesSampleRate = 1.0; options.reportPackages = false; options.addInAppInclude('sentry_flutter_example'); @@ -63,6 +67,13 @@ Future setupSentry(AppRunner appRunner, String dsn) async { options.maxRequestBodySize = MaxRequestBodySize.always; options.maxResponseBodySize = MaxResponseBodySize.always; + + _isIntegrationTest = isIntegrationTest; + if (_isIntegrationTest) { + options.dist = '1'; + options.environment = 'integration'; + options.beforeSend = beforeSendCallback; + } }, // Init your App. appRunner: appRunner); @@ -136,6 +147,7 @@ class MainScaffold extends StatelessWidget { body: SingleChildScrollView( child: Column( children: [ + if (_isIntegrationTest) const IntegrationTestWidget(), const Center(child: Text('Trigger an action:\n')), ElevatedButton( onPressed: () => sqfliteTest(), @@ -527,6 +539,53 @@ Future asyncThrows() async { throw StateError('async throws'); } +class IntegrationTestWidget extends StatefulWidget { + const IntegrationTestWidget({super.key}); + + @override + State createState() { + return _IntegrationTestWidgetState(); + } +} + +class _IntegrationTestWidgetState extends State { + _IntegrationTestWidgetState(); + + var _output = "--"; + var _isLoading = false; + + @override + Widget build(BuildContext context) { + return Column(children: [ + Text( + _output, + key: const Key('output'), + ), + _isLoading + ? const CircularProgressIndicator() + : ElevatedButton( + onPressed: () async => await _captureException(), + child: const Text('captureException'), + ) + ]); + } + + Future _captureException() async { + setState(() { + _isLoading = true; + }); + try { + throw Exception('captureException'); + } catch (error, stackTrace) { + final id = await Sentry.captureException(error, stackTrace: stackTrace); + setState(() { + _output = id.toString(); + _isLoading = false; + }); + } + } +} + class CocoaExample extends StatelessWidget { const CocoaExample({Key? key}) : super(key: key); diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 55199c83d4..648b41dd44 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: path_provider: ^2.0.0 #sqflite_common_ffi: ^2.0.0 #sqflite_common_ffi_web: ^0.3.0 + http: ^1.0.0 dev_dependencies: flutter_lints: ^2.0.0