From b728df439473e146f8721c7555cd54728a93b6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Andra=C5=A1ec?= Date: Wed, 9 Nov 2022 10:22:35 +0000 Subject: [PATCH] Feat: Screenshot Attachment (#1088) Co-authored-by: Manoel Aranda Neto Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> --- .github/workflows/flutter.yml | 3 +- .github/workflows/web-example-ghpages.yml | 1 + CHANGELOG.md | 4 + dart/lib/sentry_private.dart | 3 + .../sentry_attachment/sentry_attachment.dart | 5 + dart/lib/src/sentry_client.dart | 9 +- .../sentry_client_attachment_processor.dart | 14 +++ dart/lib/src/sentry_options.dart | 5 + dart/test/mocks.dart | 21 ++++ dart/test/sentry_attachment_test.dart | 9 ++ dart/test/sentry_client_test.dart | 40 ++++++++ flutter/example/lib/main.dart | 9 +- flutter/lib/sentry_flutter.dart | 1 + .../flutter_enricher_event_processor.dart | 3 +- .../integrations/screenshot_integration.dart | 29 ++++++ flutter/lib/src/renderer/renderer.dart | 29 +++--- .../screenshot_attachment_processor.dart | 82 ++++++++++++++++ .../screenshot/sentry_screenshot_widget.dart | 42 ++++++++ flutter/lib/src/sentry_flutter.dart | 11 +++ flutter/lib/src/sentry_flutter_options.dart | 14 +++ .../screenshot_integration_test.dart | 63 ++++++++++++ flutter/test/mocks.dart | 30 ++++++ .../screenshot_attachment_processor_test.dart | 62 ++++++++++++ flutter/test/sentry_flutter_test.dart | 96 ++++++++++++++++++- 24 files changed, 565 insertions(+), 20 deletions(-) create mode 100644 dart/lib/sentry_private.dart create mode 100644 dart/lib/src/sentry_client_attachment_processor.dart create mode 100644 flutter/lib/src/integrations/screenshot_integration.dart create mode 100644 flutter/lib/src/screenshot/screenshot_attachment_processor.dart create mode 100644 flutter/lib/src/screenshot/sentry_screenshot_widget.dart create mode 100644 flutter/test/integrations/screenshot_integration_test.dart create mode 100644 flutter/test/screenshot/screenshot_attachment_processor_test.dart diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index d515476b91..46a79df807 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -81,7 +81,8 @@ jobs: if: runner.os == 'Linux' run: | cd flutter - flutter test --platform chrome --test-randomize-ordering-seed=random + flutter test --platform chrome --test-randomize-ordering-seed=random --exclude-tags canvasKit + flutter test --platform chrome --test-randomize-ordering-seed=random --tags canvasKit --web-renderer canvaskit - name: Test VM with coverage if: runner.os != 'macOS' diff --git a/.github/workflows/web-example-ghpages.yml b/.github/workflows/web-example-ghpages.yml index 1cd5af2312..3a29c8ebdf 100644 --- a/.github/workflows/web-example-ghpages.yml +++ b/.github/workflows/web-example-ghpages.yml @@ -18,6 +18,7 @@ jobs: with: workingDir: flutter/example customArgs: --source-maps + webRenderer: canvaskit - name: Upload source maps run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9bc32dbd..cacddeb8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Feat: Screenshot Attachment ([#1088](https://github.com/getsentry/sentry-dart/pull/1088)) + ### Fixes - Merging of integrations and packages ([#1111](https://github.com/getsentry/sentry-dart/pull/1111)) diff --git a/dart/lib/sentry_private.dart b/dart/lib/sentry_private.dart new file mode 100644 index 0000000000..299519b7a5 --- /dev/null +++ b/dart/lib/sentry_private.dart @@ -0,0 +1,3 @@ +// attachments +// ignore: invalid_export_of_internal_element +export 'src/sentry_client_attachment_processor.dart'; diff --git a/dart/lib/src/sentry_attachment/sentry_attachment.dart b/dart/lib/src/sentry_attachment/sentry_attachment.dart index aaac2f85b4..ab10269cea 100644 --- a/dart/lib/src/sentry_attachment/sentry_attachment.dart +++ b/dart/lib/src/sentry_attachment/sentry_attachment.dart @@ -83,6 +83,11 @@ class SentryAttachment { addToTransactions: addToTransactions, ); + SentryAttachment.fromScreenshotData(Uint8List bytes) + : this.fromUint8List(bytes, 'screenshot.png', + contentType: 'image/png', + attachmentType: SentryAttachment.typeAttachmentDefault); + /// Attachment type. /// Should be one of types given in [AttachmentType]. final String attachmentType; diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index ec63372f7a..d74b39ca36 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -19,6 +19,7 @@ import 'sentry_envelope.dart'; import 'client_reports/client_report_recorder.dart'; import 'client_reports/discard_reason.dart'; import 'transport/data_category.dart'; +import 'sentry_client_attachment_processor.dart'; /// Default value for [User.ipAddress]. It gets set when an event does not have /// a user and IP address. Only applies if [SentryOptions.sendDefaultPii] is set @@ -37,6 +38,9 @@ class SentryClient { SentryStackTraceFactory get _stackTraceFactory => _options.stackTraceFactory; + SentryClientAttachmentProcessor get _clientAttachmentProcessor => + _options.clientAttachmentProcessor; + /// Instantiates a client using [SentryOptions] factory SentryClient(SentryOptions options) { if (options.sendClientReports) { @@ -130,12 +134,15 @@ class SentryClient { preparedEvent = _eventWithRemovedBreadcrumbsIfHandled(preparedEvent); } + final attachments = await _clientAttachmentProcessor.processAttachments( + scope?.attachments ?? [], preparedEvent); + final envelope = SentryEnvelope.fromEvent( preparedEvent, _options.sdk, dsn: _options.dsn, traceContext: scope?.span?.traceContext(), - attachments: scope?.attachments, + attachments: attachments.isNotEmpty ? attachments : null, ); final id = await captureEnvelope(envelope); diff --git a/dart/lib/src/sentry_client_attachment_processor.dart b/dart/lib/src/sentry_client_attachment_processor.dart new file mode 100644 index 0000000000..32ed178244 --- /dev/null +++ b/dart/lib/src/sentry_client_attachment_processor.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import './sentry_attachment/sentry_attachment.dart'; +import './protocol/sentry_event.dart'; + +@internal +class SentryClientAttachmentProcessor { + Future> processAttachments( + List attachments, SentryEvent event) async { + return attachments; + } +} diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index a28f9b0334..259a54d791 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:http/http.dart'; import '../sentry.dart'; +import '../sentry_private.dart'; import 'client_reports/client_report_recorder.dart'; import 'client_reports/noop_client_report_recorder.dart'; import 'sentry_exception_factory.dart'; @@ -354,6 +355,10 @@ class SentryOptions { @internal late SentryStackTraceFactory stackTraceFactory = SentryStackTraceFactory(this); + + @internal + late SentryClientAttachmentProcessor clientAttachmentProcessor = + SentryClientAttachmentProcessor(); } /// This function is called with an SDK specific event object and can return a modified event diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index 9e0ea7ff65..eafabab53a 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; +import 'package:sentry/sentry_private.dart'; import 'package:sentry/src/transport/rate_limiter.dart'; final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; @@ -160,3 +161,23 @@ class MockRateLimiter implements RateLimiter { this.errorCode = errorCode; } } + +enum MockAttachmentProcessorMode { filter, add } + +/// Filtering out all attachments. +class MockAttachmentProcessor implements SentryClientAttachmentProcessor { + MockAttachmentProcessorMode mode; + + MockAttachmentProcessor(this.mode); + + @override + Future> processAttachments( + List attachments, SentryEvent event) async { + switch (mode) { + case MockAttachmentProcessorMode.filter: + return []; + case MockAttachmentProcessorMode.add: + return [SentryAttachment.fromIntList([], "added")]; + } + } +} diff --git a/dart/test/sentry_attachment_test.dart b/dart/test/sentry_attachment_test.dart index e836755420..eafe638d3d 100644 --- a/dart/test/sentry_attachment_test.dart +++ b/dart/test/sentry_attachment_test.dart @@ -159,6 +159,15 @@ void main() { expect(attachment.addToTransactions, true); }); + + test('fromScreenshotData', () async { + final attachment = + SentryAttachment.fromScreenshotData(Uint8List.fromList([0, 0, 0, 0])); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, 'image/png'); + expect(attachment.filename, 'screenshot.png'); + expect(attachment.addToTransactions, false); + }); }); } diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 8ccaefd570..8f56d72c10 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/client_reports/client_report.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; @@ -1043,6 +1044,45 @@ void main() { }); }); + group('SentryClientAttachmentProcessor', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('processor filtering out attachments', () async { + fixture.options.clientAttachmentProcessor = + MockAttachmentProcessor(MockAttachmentProcessorMode.filter); + final scope = Scope(fixture.options); + scope.addAttachment(SentryAttachment.fromIntList([], "scope-attachment")); + final sut = fixture.getSut(); + + final event = SentryEvent(); + await sut.captureEvent(event, scope: scope); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final attachmentItem = capturedEnvelope.items.firstWhereOrNull( + (element) => element.header.type == SentryItemType.attachment); + expect(attachmentItem, null); + }); + + test('processor adding attachments', () async { + fixture.options.clientAttachmentProcessor = + MockAttachmentProcessor(MockAttachmentProcessorMode.add); + final scope = Scope(fixture.options); + final sut = fixture.getSut(); + + final event = SentryEvent(); + await sut.captureEvent(event, scope: scope); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final attachmentItem = capturedEnvelope.items.firstWhereOrNull( + (element) => element.header.type == SentryItemType.attachment); + expect(attachmentItem != null, true); + }); + }); + group('ClientReportRecorder', () { late Fixture fixture; diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 89b83d8bc2..db1d35d908 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -31,6 +31,7 @@ Future main() async { options.attachThreads = true; options.enableWindowMetricBreadcrumbs = true; options.addIntegration(LoggingIntegration()); + options.attachScreenshot = true; // We can enable Sentry debug logging during development. This is likely // going to log too much for your app, but can be useful when figuring out // configuration issues, e.g. finding out why your events are not uploaded. @@ -38,9 +39,11 @@ Future main() async { }, // Init your App. appRunner: () => runApp( - DefaultAssetBundle( - bundle: SentryAssetBundle(enableStructuredDataTracing: true), - child: MyApp(), + SentryScreenshotWidget( + child: DefaultAssetBundle( + bundle: SentryAssetBundle(enableStructuredDataTracing: true), + child: MyApp(), + ), ), ), ); diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index 1dfd553789..439e4e60ad 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -8,3 +8,4 @@ export 'src/sentry_flutter_options.dart'; export 'src/flutter_sentry_attachment.dart'; export 'src/sentry_asset_bundle.dart'; export 'src/integrations/on_error_integration.dart'; +export 'src/screenshot/sentry_screenshot_widget.dart'; 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 74fe8c6ae0..5c23a00161 100644 --- a/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart +++ b/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:sentry/sentry.dart'; import '../binding_utils.dart'; -import '../renderer/renderer.dart'; import '../sentry_flutter_options.dart'; typedef WidgetBindingGetter = WidgetsBinding? Function(); @@ -155,7 +154,7 @@ class FlutterEnricherEventProcessor extends EventProcessor { // Also always fails in tests. // See https://github.com/flutter/flutter/issues/83919 // 'window_is_visible': _window.viewConfiguration.visible, - 'renderer': getRendererAsString() + 'renderer': _options.rendererWrapper.getRendererAsString() }; } diff --git a/flutter/lib/src/integrations/screenshot_integration.dart b/flutter/lib/src/integrations/screenshot_integration.dart new file mode 100644 index 0000000000..aa0e2abfc7 --- /dev/null +++ b/flutter/lib/src/integrations/screenshot_integration.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/sentry_private.dart'; +import '../screenshot/screenshot_attachment_processor.dart'; +import '../sentry_flutter_options.dart'; + +/// Adds [ScreenshotAttachmentProcessor] to options if [attachScreenshot] is true +class ScreenshotIntegration implements Integration { + SentryFlutterOptions? _options; + + @override + FutureOr call(Hub hub, SentryFlutterOptions options) { + if (options.attachScreenshot) { + // ignore: invalid_use_of_internal_member + options.clientAttachmentProcessor = + ScreenshotAttachmentProcessor(options); + _options = options; + + options.sdk.addIntegration('screenshotIntegration'); + } + } + + @override + FutureOr close() { + // ignore: invalid_use_of_internal_member + _options?.clientAttachmentProcessor = SentryClientAttachmentProcessor(); + } +} diff --git a/flutter/lib/src/renderer/renderer.dart b/flutter/lib/src/renderer/renderer.dart index 87a6082c61..5fdc238716 100644 --- a/flutter/lib/src/renderer/renderer.dart +++ b/flutter/lib/src/renderer/renderer.dart @@ -1,19 +1,26 @@ +import 'package:meta/meta.dart'; + import 'unknown_renderer.dart' if (dart.library.html) 'html_renderer.dart' if (dart.library.io) 'io_renderer.dart' as implementation; -FlutterRenderer getRenderer() => implementation.getRenderer(); +@internal +class RendererWrapper { + FlutterRenderer getRenderer() { + return implementation.getRenderer(); + } -String getRendererAsString() { - switch (getRenderer()) { - case FlutterRenderer.skia: - return 'Skia'; - case FlutterRenderer.canvasKit: - return 'CanvasKit'; - case FlutterRenderer.html: - return 'HTML'; - case FlutterRenderer.unknown: - return 'Unknown'; + String getRendererAsString() { + switch (getRenderer()) { + case FlutterRenderer.skia: + return 'Skia'; + case FlutterRenderer.canvasKit: + return 'CanvasKit'; + case FlutterRenderer.html: + return 'HTML'; + case FlutterRenderer.unknown: + return 'Unknown'; + } } } diff --git a/flutter/lib/src/screenshot/screenshot_attachment_processor.dart b/flutter/lib/src/screenshot/screenshot_attachment_processor.dart new file mode 100644 index 0000000000..afc0f70989 --- /dev/null +++ b/flutter/lib/src/screenshot/screenshot_attachment_processor.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui show ImageByteFormat; + +import 'package:flutter/rendering.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/sentry_private.dart'; +import '../renderer/renderer.dart'; +import '../sentry_flutter_options.dart'; +import 'sentry_screenshot_widget.dart'; + +// ignore: invalid_use_of_internal_member +class ScreenshotAttachmentProcessor implements SentryClientAttachmentProcessor { + final SentryFlutterOptions _options; + + ScreenshotAttachmentProcessor(this._options); + + /// This is true when the SentryWidget is in the view hierarchy + bool get _hasSentryScreenshotWidget => + sentryScreenshotWidgetGlobalKey.currentContext != null; + + @override + Future> processAttachments( + List attachments, SentryEvent event) async { + if (event.exceptions == null && + event.throwable == null && + _hasSentryScreenshotWidget) { + return attachments; + } + final renderer = _options.rendererWrapper.getRenderer(); + if (renderer != FlutterRenderer.skia && + renderer != FlutterRenderer.canvasKit) { + _options.logger(SentryLevel.debug, + 'Cannot take screenshot with ${_options.rendererWrapper.getRendererAsString()} renderer.'); + return attachments; + } + + final bytes = await _createScreenshot(); + if (bytes != null) { + return attachments + [SentryAttachment.fromScreenshotData(bytes)]; + } else { + return attachments; + } + } + + Future _createScreenshot() async { + try { + final renderObject = + sentryScreenshotWidgetGlobalKey.currentContext?.findRenderObject(); + + if (renderObject is RenderRepaintBoundary) { + final image = await renderObject.toImage(pixelRatio: 1); + // At the time of writing there's no other image format available which + // Sentry understands. + + if (image.width == 0 || image.height == 0) { + _options.logger(SentryLevel.debug, + 'View\'s width and height is zeroed, not taking screenshot.'); + return null; + } + + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + final bytes = byteData?.buffer.asUint8List(); + if (bytes?.isNotEmpty == true) { + return bytes; + } else { + _options.logger(SentryLevel.debug, + 'Screenshot is 0 bytes, not attaching the image.'); + return null; + } + } + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'Taking screenshot failed.', + exception: exception, + stackTrace: stackTrace, + ); + } + return null; + } +} diff --git a/flutter/lib/src/screenshot/sentry_screenshot_widget.dart b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart new file mode 100644 index 0000000000..b11b511bb3 --- /dev/null +++ b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +/// Key which is used to identify the [RepaintBoundary] +@internal +final sentryScreenshotWidgetGlobalKey = + GlobalKey(debugLabel: 'sentry_screenshot_widget'); + +/// You can add screenshots of [child] to crash reports by adding this widget. +/// Ideally you are adding it around your app widget like in the following +/// example. +/// ```dart +/// runApp(SentryScreenshotWidget(child: App())); +/// ``` +/// +/// Remarks: +/// - Depending on the place where it's used, you might have a transparent +/// background. +/// - Platform Views currently can't be captured. +/// - Works with skia renderer & canvas kit renderer if running on web. For more +/// information see https://flutter.dev/docs/development/tools/web-renderers +/// - You can only have one [SentryScreenshotWidget] widget in your widget tree at all +/// times. +class SentryScreenshotWidget extends StatefulWidget { + const SentryScreenshotWidget({Key? key, required this.child}) + : super(key: key); + + final Widget child; + + @override + _SentryScreenshotWidgetState createState() => _SentryScreenshotWidgetState(); +} + +class _SentryScreenshotWidgetState extends State { + @override + Widget build(BuildContext context) { + return RepaintBoundary( + key: sentryScreenshotWidgetGlobalKey, + child: widget.child, + ); + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 680afb9b4b..fa83d58e47 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -7,7 +7,9 @@ import 'package:meta/meta.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../sentry_flutter.dart'; import 'event_processor/android_platform_exception_event_processor.dart'; +import 'integrations/screenshot_integration.dart'; import 'native_scope_observer.dart'; +import 'renderer/renderer.dart'; import 'sentry_native.dart'; import 'sentry_native_channel.dart'; @@ -32,12 +34,16 @@ mixin SentryFlutter { @internal PackageLoader packageLoader = _loadPackageInfo, @internal MethodChannel channel = _channel, @internal PlatformChecker? platformChecker, + @internal RendererWrapper? rendererWrapper, }) async { final flutterOptions = SentryFlutterOptions(); if (platformChecker != null) { flutterOptions.platformChecker = platformChecker; } + if (rendererWrapper != null) { + flutterOptions.rendererWrapper = rendererWrapper; + } final nativeChannel = SentryNativeChannel(channel, flutterOptions); final native = SentryNative(); @@ -140,6 +146,11 @@ mixin SentryFlutter { (platform.isAndroid || platform.isIOS || platform.isMacOS)) { integrations.add(LoadImageListIntegration(channel)); } + final renderer = options.rendererWrapper.getRenderer(); + if (renderer == FlutterRenderer.skia || + renderer == FlutterRenderer.canvasKit) { + integrations.add(ScreenshotIntegration()); + } integrations.add(DebugPrintIntegration()); diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 9026d48e3f..efd9326dc3 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -1,5 +1,8 @@ +import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; +import 'renderer/renderer.dart'; + /// This class adds options which are only availble in a Flutter environment. /// Note that some of these options require native Sentry integration, which is /// not available on all platforms. @@ -177,6 +180,17 @@ class SentryFlutterOptions extends SentryOptions { /// [SentryFlutter.setAppStartEnd]. bool autoAppStart = true; + /// Automatically attaches a screenshot when capturing an error or exception. + /// + /// Requires adding the [SentryScreenshotWidget] to the widget tree. + /// Example: + /// runApp(SentryScreenshotWidget(child: App())); + /// The [SentryScreenshotWidget] has to be the root widget of the app. + bool attachScreenshot = false; + + @internal + late RendererWrapper rendererWrapper = RendererWrapper(); + /// 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/test/integrations/screenshot_integration_test.dart b/flutter/test/integrations/screenshot_integration_test.dart new file mode 100644 index 0000000000..bfd29ec8d6 --- /dev/null +++ b/flutter/test/integrations/screenshot_integration_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; +import 'package:sentry_flutter/src/screenshot/screenshot_attachment_processor.dart'; + +import '../mocks.mocks.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('screenshotIntegration creates screenshot processor', () async { + final integration = fixture.getSut(); + + await integration(fixture.hub, fixture.options); + + expect( + // ignore: invalid_use_of_internal_member + fixture.options.clientAttachmentProcessor + is ScreenshotAttachmentProcessor, + true); + }); + + test( + 'screenshotIntegration does not creates screenshot processor if opt out in options', + () async { + final integration = fixture.getSut(attachScreenshot: false); + + await integration(fixture.hub, fixture.options); + + expect( + // ignore: invalid_use_of_internal_member + fixture.options.clientAttachmentProcessor + is ScreenshotAttachmentProcessor, + false); + }); + + test('screenshotIntegration close resets processor', () async { + final integration = fixture.getSut(); + + await integration(fixture.hub, fixture.options); + await integration.close(); + + expect( + // ignore: invalid_use_of_internal_member + fixture.options.clientAttachmentProcessor + is ScreenshotAttachmentProcessor, + false); + }); +} + +class Fixture { + final hub = MockHub(); + final options = SentryFlutterOptions(); + + ScreenshotIntegration getSut({bool attachScreenshot = true}) { + options.attachScreenshot = attachScreenshot; + return ScreenshotIntegration(); + } +} diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index 0070aaf7f6..6aad525003 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -7,6 +7,7 @@ import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:meta/meta.dart'; +import 'package:sentry_flutter/src/renderer/renderer.dart'; import 'package:sentry_flutter/src/sentry_native.dart'; import 'package:sentry_flutter/src/sentry_native_channel.dart'; @@ -71,6 +72,10 @@ class MockPlatform with NoSuchMethodProvider implements Platform { return MockPlatform(os: 'linux'); } + factory MockPlatform.fuchsia() { + return MockPlatform(os: 'fuchsia'); + } + @override String operatingSystem; @@ -224,3 +229,28 @@ class MockNativeChannel implements SentryNativeChannel { numberOfSetTagCalls += 1; } } + +class MockRendererWrapper implements RendererWrapper { + MockRendererWrapper(this._renderer); + + final FlutterRenderer _renderer; + + @override + FlutterRenderer getRenderer() { + return _renderer; + } + + @override + String getRendererAsString() { + switch (getRenderer()) { + case FlutterRenderer.skia: + return 'Skia'; + case FlutterRenderer.canvasKit: + return 'CanvasKit'; + case FlutterRenderer.html: + return 'HTML'; + case FlutterRenderer.unknown: + return 'Unknown'; + } + } +} diff --git a/flutter/test/screenshot/screenshot_attachment_processor_test.dart b/flutter/test/screenshot/screenshot_attachment_processor_test.dart new file mode 100644 index 0000000000..c27c5b42a0 --- /dev/null +++ b/flutter/test/screenshot/screenshot_attachment_processor_test.dart @@ -0,0 +1,62 @@ +@Tags(['canvasKit']) // Web renderer where this test can run + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/renderer/renderer.dart'; +import 'package:sentry_flutter/src/screenshot/screenshot_attachment_processor.dart'; +import '../mocks.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +void main() { + late Fixture fixture; + setUp(() { + fixture = Fixture(); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + Future _addScreenshotAttachment( + WidgetTester tester, FlutterRenderer renderer, bool added) async { + // Run with real async https://stackoverflow.com/a/54021863 + await tester.runAsync(() async { + final sut = fixture.getSut(renderer); + + await tester.pumpWidget(SentryScreenshotWidget( + child: Text('Catching Pokémon is a snap!', + textDirection: TextDirection.ltr))); + + final throwable = Exception(); + final event = SentryEvent(throwable: throwable); + final attachments = await sut.processAttachments([], event); + + expect(attachments.isNotEmpty, added); + }); + } + + testWidgets('adds screenshot attachment with skia renderer', (tester) async { + await _addScreenshotAttachment(tester, FlutterRenderer.skia, true); + }); + + testWidgets('adds screenshot attachment with canvasKit renderer', + (tester) async { + await _addScreenshotAttachment(tester, FlutterRenderer.canvasKit, true); + }); + + testWidgets('does not add screenshot attachment with html renderer', + (tester) async { + await _addScreenshotAttachment(tester, FlutterRenderer.html, false); + }); + + testWidgets('does not add screenshot attachment with unknown renderer', + (tester) async { + await _addScreenshotAttachment(tester, FlutterRenderer.unknown, false); + }); +} + +class Fixture { + SentryFlutterOptions options = SentryFlutterOptions(dsn: fakeDsn); + + ScreenshotAttachmentProcessor getSut(FlutterRenderer flutterRenderer) { + options.rendererWrapper = MockRendererWrapper(flutterRenderer); + return ScreenshotAttachmentProcessor(options); + } +} diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index f57d8527be..7315d8aa66 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/integrations.dart'; +import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; +import 'package:sentry_flutter/src/renderer/renderer.dart'; import 'package:sentry_flutter/src/version.dart'; import 'mocks.dart'; import 'mocks.mocks.dart'; @@ -321,7 +323,7 @@ void main() { List integrations = []; Transport transport = MockTransport(); - // Tests that iOS || macOS integrations aren't added on a browswer which + // Tests that iOS || macOS integrations aren't added on a browser which // runs on iOS or macOS await SentryFlutter.init( (options) async { @@ -364,7 +366,7 @@ void main() { List integrations = []; Transport transport = MockTransport(); - // Tests that Android integrations aren't added on an Android browswer + // Tests that Android integrations aren't added on an Android browser await SentryFlutter.init( (options) async { options.dsn = fakeDsn; @@ -403,6 +405,96 @@ void main() { }); }); + group('Test ScreenshotIntegration', () { + setUp(() async { + await Sentry.close(); + }); + + test('installed with skia renderer', () async { + List integrations = []; + + await SentryFlutter.init((options) async { + options.dsn = fakeDsn; + integrations = options.integrations; + }, + appRunner: appRunner, + packageLoader: loadTestPackage, + platformChecker: getPlatformChecker(platform: MockPlatform.iOs()), + rendererWrapper: MockRendererWrapper(FlutterRenderer.skia)); + + expect( + integrations + .map((e) => e.runtimeType) + .contains(ScreenshotIntegration), + true); + + await Sentry.close(); + }, testOn: 'vm'); + + test('installed with canvasKit renderer', () async { + List integrations = []; + + await SentryFlutter.init((options) async { + options.dsn = fakeDsn; + integrations = options.integrations; + }, + appRunner: appRunner, + packageLoader: loadTestPackage, + platformChecker: getPlatformChecker(platform: MockPlatform.iOs()), + rendererWrapper: MockRendererWrapper(FlutterRenderer.canvasKit)); + + expect( + integrations + .map((e) => e.runtimeType) + .contains(ScreenshotIntegration), + true); + + await Sentry.close(); + }, testOn: 'vm'); + + test('not installed with html renderer', () async { + List integrations = []; + + await SentryFlutter.init((options) async { + options.dsn = fakeDsn; + integrations = options.integrations; + }, + appRunner: appRunner, + packageLoader: loadTestPackage, + platformChecker: getPlatformChecker(platform: MockPlatform.iOs()), + rendererWrapper: MockRendererWrapper(FlutterRenderer.html)); + + expect( + integrations + .map((e) => e.runtimeType) + .contains(ScreenshotIntegration), + false); + + await Sentry.close(); + }, testOn: 'vm'); + + test('not installed with unknown renderer', () async { + List integrations = []; + + await SentryFlutter.init((options) async { + options.dsn = fakeDsn; + integrations = options.integrations; + }, + appRunner: appRunner, + packageLoader: loadTestPackage, + platformChecker: getPlatformChecker(platform: MockPlatform.iOs()), + rendererWrapper: MockRendererWrapper(FlutterRenderer.unknown)); + + expect( + integrations + .map((e) => e.runtimeType) + .contains(ScreenshotIntegration), + false); + + await Sentry.close(); + }, testOn: 'vm'); + }); + group('initial values', () { setUp(() async { await Sentry.close();