|
| 1 | +// ignore_for_file: invalid_use_of_internal_member |
| 2 | + |
| 3 | +import 'dart:async'; |
1 | 4 | import 'dart:io'; |
2 | 5 |
|
| 6 | +import 'package:flutter/widgets.dart'; |
3 | 7 | import 'package:flutter_test/flutter_test.dart'; |
| 8 | +import 'package:integration_test/integration_test.dart'; |
4 | 9 | import 'package:sentry_flutter/sentry_flutter.dart'; |
| 10 | +import 'package:sentry_flutter_example/main.dart'; |
| 11 | +import 'package:sentry_flutter/src/native/java/sentry_native_java.dart'; |
| 12 | +import 'package:sentry_flutter/src/replay/replay_config.dart'; |
| 13 | +import 'package:sentry_flutter/src/native/cocoa/sentry_native_cocoa.dart'; |
| 14 | +import 'package:sentry_flutter/src/replay/scheduled_recorder_config.dart'; |
5 | 15 |
|
6 | 16 | void main() { |
| 17 | + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); |
| 18 | + IntegrationTestWidgetsFlutterBinding.instance.framePolicy = |
| 19 | + LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; |
| 20 | + |
| 21 | + const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; |
| 22 | + |
| 23 | + tearDown(() async { |
| 24 | + await Sentry.close(); |
| 25 | + }); |
| 26 | + |
| 27 | + Future<void> setupSentryAndApp(WidgetTester tester, {String? dsn}) async { |
| 28 | + await setupSentry( |
| 29 | + () async { |
| 30 | + await tester.pumpWidget(SentryScreenshotWidget( |
| 31 | + child: DefaultAssetBundle( |
| 32 | + bundle: SentryAssetBundle(enableStructuredDataTracing: true), |
| 33 | + child: const MyApp(), |
| 34 | + ))); |
| 35 | + }, |
| 36 | + dsn ?? fakeDsn, |
| 37 | + isIntegrationTest: true, |
| 38 | + ); |
| 39 | + } |
| 40 | + |
7 | 41 | group('Replay recording', () { |
8 | | - setUp(() async { |
9 | | - await SentryFlutter.init((options) { |
10 | | - // ignore: invalid_use_of_internal_member |
11 | | - options.automatedTestMode = true; |
12 | | - options.dsn = 'https://abc@def.ingest.sentry.io/1234567'; |
13 | | - options.debug = true; |
14 | | - options.replay.sessionSampleRate = 1.0; |
15 | | - }); |
| 42 | + testWidgets('native binding is initialized', (tester) async { |
| 43 | + await setupSentryAndApp(tester); |
| 44 | + expect(SentryFlutter.native, isNotNull); |
16 | 45 | }); |
17 | 46 |
|
18 | | - tearDown(() async { |
19 | | - await Sentry.close(); |
| 47 | + testWidgets('supportsReplay matches platform', (tester) async { |
| 48 | + await setupSentryAndApp(tester); |
| 49 | + final supports = SentryFlutter.native?.supportsReplay ?? false; |
| 50 | + expect(supports, Platform.isAndroid || Platform.isIOS ? isTrue : isFalse); |
20 | 51 | }); |
21 | 52 |
|
22 | | - test('native binding is initialized', () async { |
23 | | - // ignore: invalid_use_of_internal_member |
24 | | - expect(SentryFlutter.native, isNotNull); |
| 53 | + testWidgets('captureReplay returns a SentryId', (tester) async { |
| 54 | + if (!(Platform.isAndroid || Platform.isIOS)) return; |
| 55 | + await setupSentryAndApp(tester); |
| 56 | + final id = await SentryFlutter.native?.captureReplay(); |
| 57 | + expect(id, isA<SentryId>()); |
| 58 | + if (Platform.isIOS) { |
| 59 | + final current = SentryFlutter.native?.replayId; |
| 60 | + expect(current?.toString(), equals(id?.toString())); |
| 61 | + } |
| 62 | + }); |
| 63 | + |
| 64 | + testWidgets('captureReplay sets native replay ID', (tester) async { |
| 65 | + if (!(Platform.isAndroid || Platform.isIOS)) return; |
| 66 | + await setupSentryAndApp(tester); |
| 67 | + final id = await SentryFlutter.native?.captureReplay(); |
| 68 | + expect(id, isA<SentryId>()); |
| 69 | + expect(SentryFlutter.native?.replayId, isNotNull); |
| 70 | + expect(SentryFlutter.native?.replayId, isNot(const SentryId.empty())); |
25 | 71 | }); |
26 | 72 |
|
27 | | - test('session replay is captured', () async { |
28 | | - // TODO add when the beforeSend callback is implemented for replays. |
29 | | - }, skip: true); |
30 | | - |
31 | | - test('replay is captured on errors', () async { |
32 | | - // TODO we may need an HTTP server for this because Android sends replays |
33 | | - // in a separate envelope. |
34 | | - }, skip: true); |
35 | | - }, |
36 | | - skip: Platform.isAndroid |
37 | | - ? false |
38 | | - : "Replay recording is not supported on this platform"); |
| 73 | + // We would like to add a test that ensures a native-initiated replay stop |
| 74 | + // clears the replay ID from the scope. Currently we can't add that test |
| 75 | + // because FFI/JNI cannot be mocked in this environment. |
| 76 | + testWidgets('sets replay ID after capturing exception', (tester) async { |
| 77 | + await setupSentryAndApp(tester); |
| 78 | + |
| 79 | + try { |
| 80 | + throw Exception('boom'); |
| 81 | + } catch (e, st) { |
| 82 | + await Sentry.captureException(e, stackTrace: st); |
| 83 | + } |
| 84 | + |
| 85 | + // After capture, ReplayEventProcessor should set scope.replayId |
| 86 | + await Sentry.configureScope((scope) async { |
| 87 | + expect( |
| 88 | + scope.replayId == null || scope.replayId == const SentryId.empty(), |
| 89 | + isFalse); |
| 90 | + }); |
| 91 | + |
| 92 | + final current = SentryFlutter.native?.replayId; |
| 93 | + await Sentry.configureScope((scope) async { |
| 94 | + expect(current?.toString(), equals(scope.replayId?.toString())); |
| 95 | + }); |
| 96 | + }); |
| 97 | + |
| 98 | + testWidgets( |
| 99 | + 'replay recorder start emits frame and stop silences frames on Android', |
| 100 | + (tester) async { |
| 101 | + await setupSentryAndApp(tester); |
| 102 | + final native = SentryFlutter.native as SentryNativeJava?; |
| 103 | + expect(native, isNotNull); |
| 104 | + |
| 105 | + await Future.delayed(const Duration(seconds: 2)); |
| 106 | + final recorder = native!.testRecorder; |
| 107 | + expect(recorder, isNotNull); |
| 108 | + |
| 109 | + await recorder! |
| 110 | + .onConfigurationChanged(const ScheduledScreenshotRecorderConfig( |
| 111 | + width: 800, |
| 112 | + height: 600, |
| 113 | + frameRate: 1, |
| 114 | + )); |
| 115 | + |
| 116 | + var frameCount = 0; |
| 117 | + final firstFrame = Completer<void>(); |
| 118 | + recorder.onScreenshotAddedForTest = () { |
| 119 | + frameCount++; |
| 120 | + if (!firstFrame.isCompleted) firstFrame.complete(); |
| 121 | + }; |
| 122 | + |
| 123 | + await tester.pump(); |
| 124 | + await firstFrame.future.timeout(const Duration(seconds: 5)); |
| 125 | + |
| 126 | + await recorder.stop(); |
| 127 | + await tester.pump(); |
| 128 | + final afterStopCount = frameCount; |
| 129 | + await Future<void>.delayed(const Duration(seconds: 2)); |
| 130 | + expect(frameCount, equals(afterStopCount)); |
| 131 | + }, skip: !Platform.isAndroid); |
| 132 | + |
| 133 | + testWidgets( |
| 134 | + 'replay recorder pause silences and resume restarts frames on Android', |
| 135 | + (tester) async { |
| 136 | + await setupSentryAndApp(tester); |
| 137 | + final native = SentryFlutter.native as SentryNativeJava?; |
| 138 | + expect(native, isNotNull); |
| 139 | + |
| 140 | + await Future.delayed(const Duration(seconds: 2)); |
| 141 | + final recorder = native!.testRecorder; |
| 142 | + expect(recorder, isNotNull); |
| 143 | + |
| 144 | + await recorder! |
| 145 | + .onConfigurationChanged(const ScheduledScreenshotRecorderConfig( |
| 146 | + width: 800, |
| 147 | + height: 600, |
| 148 | + frameRate: 1, |
| 149 | + )); |
| 150 | + |
| 151 | + var frameCount = 0; |
| 152 | + final firstFrame = Completer<void>(); |
| 153 | + recorder.onScreenshotAddedForTest = () { |
| 154 | + frameCount++; |
| 155 | + if (!firstFrame.isCompleted) firstFrame.complete(); |
| 156 | + }; |
| 157 | + |
| 158 | + await tester.pump(); |
| 159 | + await firstFrame.future.timeout(const Duration(seconds: 5)); |
| 160 | + |
| 161 | + await recorder.pause(); |
| 162 | + await tester.pump(); |
| 163 | + final pausedCount = frameCount; |
| 164 | + await Future<void>.delayed(const Duration(seconds: 2)); |
| 165 | + expect(frameCount, equals(pausedCount)); |
| 166 | + |
| 167 | + await recorder.resume(); |
| 168 | + await tester.pump(); |
| 169 | + final resumedBaseline = frameCount; |
| 170 | + await Future<void>.delayed(const Duration(seconds: 3)); |
| 171 | + expect(frameCount, greaterThan(resumedBaseline)); |
| 172 | + |
| 173 | + await recorder.stop(); |
| 174 | + await tester.pump(); |
| 175 | + final afterStopCount = frameCount; |
| 176 | + await Future<void>.delayed(const Duration(seconds: 2)); |
| 177 | + expect(frameCount, equals(afterStopCount)); |
| 178 | + }, skip: !Platform.isAndroid); |
| 179 | + |
| 180 | + testWidgets('setReplayConfig applies without error on Android', |
| 181 | + (tester) async { |
| 182 | + await setupSentryAndApp(tester); |
| 183 | + const config = ReplayConfig( |
| 184 | + windowWidth: 1080, |
| 185 | + windowHeight: 1920, |
| 186 | + width: 800, |
| 187 | + height: 600, |
| 188 | + frameRate: 1, |
| 189 | + ); |
| 190 | + await Future.delayed(const Duration(seconds: 2)); |
| 191 | + |
| 192 | + // Should not throw |
| 193 | + await SentryFlutter.native?.setReplayConfig(config); |
| 194 | + }, skip: !Platform.isAndroid); |
| 195 | + |
| 196 | + testWidgets('capture screenshot via test recorder returns metadata on iOS', |
| 197 | + (tester) async { |
| 198 | + await setupSentryAndApp(tester); |
| 199 | + final native = SentryFlutter.native as SentryNativeCocoa?; |
| 200 | + expect(native, isNotNull); |
| 201 | + |
| 202 | + await Future.delayed(const Duration(seconds: 2)); |
| 203 | + final json = await native!.testRecorder?.captureScreenshot(); |
| 204 | + expect(json, isNotNull); |
| 205 | + expect(json!['length'], isNotNull); |
| 206 | + expect(json['address'], isNotNull); |
| 207 | + expect(json['width'], isNotNull); |
| 208 | + expect(json['height'], isNotNull); |
| 209 | + expect((json['width'] as int) > 0, isTrue); |
| 210 | + expect((json['height'] as int) > 0, isTrue); |
| 211 | + |
| 212 | + // Capture again to ensure subsequent captures still succeed |
| 213 | + final json2 = await native.testRecorder?.captureScreenshot(); |
| 214 | + expect(json2, isNotNull); |
| 215 | + }, skip: !Platform.isIOS); |
| 216 | + }); |
39 | 217 | } |
0 commit comments