// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:convert'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/os.dart'; import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/doctor.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:flutter_tools/src/reporting/crash_reporting.dart'; import 'package:flutter_tools/src/reporting/reporting.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; import 'package:test/fake.dart'; import '../src/common.dart'; import '../src/fake_process_manager.dart'; void main() { late BufferLogger logger; late FileSystem fs; late TestUsage testUsage; late Platform platform; late OperatingSystemUtils operatingSystemUtils; late StackTrace stackTrace; setUp(() async { logger = BufferLogger.test(); fs = MemoryFileSystem.test(); testUsage = TestUsage(); platform = FakePlatform(environment: <String, String>{}); operatingSystemUtils = OperatingSystemUtils( fileSystem: fs, logger: logger, platform: platform, processManager: FakeProcessManager.any(), ); MockCrashReportSender.sendCalls = 0; stackTrace = StackTrace.fromString(''' #0 _File.open.<anonymous closure> (dart:io/file_impl.dart:366:9) #1 _rootRunUnary (dart:async/zone.dart:1141:38)'''); }); Future<void> verifyCrashReportSent(RequestInfo crashInfo, { int crashes = 1, }) async { // Verify that we sent the crash report. expect(crashInfo.method, 'POST'); expect(crashInfo.uri, Uri( scheme: 'https', host: 'clients2.google.com', port: 443, path: '/cr/report', queryParameters: <String, String>{ 'product': 'Flutter_Tools', 'version': 'test-version', }, )); expect(crashInfo.fields?['uuid'], testUsage.clientId); expect(crashInfo.fields?['product'], 'Flutter_Tools'); expect(crashInfo.fields?['version'], 'test-version'); expect(crashInfo.fields?['osName'], 'linux'); expect(crashInfo.fields?['osVersion'], 'Linux'); expect(crashInfo.fields?['type'], 'DartError'); expect(crashInfo.fields?['error_runtime_type'], 'StateError'); expect(crashInfo.fields?['error_message'], 'Bad state: Test bad state error'); expect(crashInfo.fields?['comments'], 'crash'); expect(logger.traceText, contains('Sending crash report to Google.')); expect(logger.traceText, contains('Crash report sent (report ID: test-report-id)')); } testWithoutContext('CrashReporter.informUser provides basic instructions without PII', () async { final CrashReporter crashReporter = CrashReporter( fileSystem: fs, logger: logger, flutterProjectFactory: FlutterProjectFactory(fileSystem: fs, logger: logger), ); final File file = fs.file('flutter_00.log'); await crashReporter.informUser( CrashDetails( command: 'arg1 arg2 arg3', error: Exception('Dummy exception'), stackTrace: StackTrace.current, // Spaces are URL query encoded in the output, make it one word to make this test simpler. doctorText: FakeDoctorText('Ignored', 'NoPIIFakeDoctorText'), ), file, ); expect(logger.statusText, contains('NoPIIFakeDoctorText')); expect(logger.statusText, isNot(contains('Ignored'))); expect(logger.statusText, contains('https://github.com/flutter/flutter/issues/new')); expect(logger.errorText, contains('A crash report has been written to ${file.path}.')); }); testWithoutContext('suppress analytics', () async { testUsage.suppressAnalytics = true; final CrashReportSender crashReportSender = CrashReportSender( client: CrashingCrashReportSender(const SocketException('no internets')), usage: testUsage, platform: platform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); expect(logger.traceText, isEmpty); }); group('allow analytics', () { setUp(() async { testUsage.suppressAnalytics = false; }); testWithoutContext('should send crash reports', () async { final RequestInfo requestInfo = RequestInfo(); final CrashReportSender crashReportSender = CrashReportSender( client: MockCrashReportSender(requestInfo), usage: testUsage, platform: platform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); await verifyCrashReportSent(requestInfo); }); testWithoutContext('should print an explanatory message when there is a SocketException', () async { final CrashReportSender crashReportSender = CrashReportSender( client: CrashingCrashReportSender(const SocketException('no internets')), usage: testUsage, platform: platform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); expect(logger.errorText, contains('Failed to send crash report due to a network error')); }); testWithoutContext('should print an explanatory message when there is an HttpException', () async { final CrashReportSender crashReportSender = CrashReportSender( client: CrashingCrashReportSender(const HttpException('no internets')), usage: testUsage, platform: platform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); expect(logger.errorText, contains('Failed to send crash report due to a network error')); }); testWithoutContext('should print an explanatory message when there is a ClientException', () async { final CrashReportSender crashReportSender = CrashReportSender( client: CrashingCrashReportSender(const HttpException('no internets')), usage: testUsage, platform: platform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: ClientException('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); expect(logger.errorText, contains('Failed to send crash report due to a network error')); }); testWithoutContext('should send only one crash report when sent many times', () async { final RequestInfo requestInfo = RequestInfo(); final CrashReportSender crashReportSender = CrashReportSender( client: MockCrashReportSender(requestInfo), usage: testUsage, platform: platform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); expect(MockCrashReportSender.sendCalls, 1); await verifyCrashReportSent(requestInfo, crashes: 4); }); testWithoutContext('should not send a crash report if on a user-branch', () async { String? method; Uri? uri; final MockClient mockClient = MockClient((Request request) async { method = request.method; uri = request.url; return Response( 'test-report-id', 200, ); }); final CrashReportSender crashReportSender = CrashReportSender( client: mockClient, usage: testUsage, platform: platform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => '[user-branch]/v1.2.3', command: 'crash', ); // Verify that the report wasn't sent expect(method, null); expect(uri, null); expect(logger.traceText, isNot(contains('Crash report sent'))); }); testWithoutContext('can override base URL', () async { Uri? uri; final MockClient mockClient = MockClient((Request request) async { uri = request.url; return Response('test-report-id', 200); }); final Platform environmentPlatform = FakePlatform( environment: <String, String>{ 'HOME': '/', 'FLUTTER_CRASH_SERVER_BASE_URL': 'https://localhost:12345/fake_server', }, script: Uri(scheme: 'data'), ); final CrashReportSender crashReportSender = CrashReportSender( client: mockClient, usage: testUsage, platform: environmentPlatform, logger: logger, operatingSystemUtils: operatingSystemUtils, ); await crashReportSender.sendReport( error: StateError('Test bad state error'), stackTrace: stackTrace, getFlutterVersion: () => 'test-version', command: 'crash', ); // Verify that we sent the crash report. expect(uri, isNotNull); expect(uri, Uri( scheme: 'https', host: 'localhost', port: 12345, path: '/fake_server', queryParameters: <String, String>{ 'product': 'Flutter_Tools', 'version': 'test-version', }, )); }); }); } class RequestInfo { String? method; Uri? uri; Map<String, String>? fields; } class MockCrashReportSender extends MockClient { MockCrashReportSender(RequestInfo crashInfo) : super((Request request) async { MockCrashReportSender.sendCalls++; crashInfo.method = request.method; crashInfo.uri = request.url; // A very ad-hoc multipart request parser. Good enough for this test. String? boundary = request.headers['Content-Type']; boundary = boundary?.substring(boundary.indexOf('boundary=') + 9); crashInfo.fields = Map<String, String>.fromIterable( utf8.decode(request.bodyBytes) .split('--$boundary') .map<List<String>?>((String part) { final Match? nameMatch = RegExp(r'name="(.*)"').firstMatch(part); if (nameMatch == null) { return null; } final String name = nameMatch[1]!; final String value = part.split('\n').skip(2).join('\n').trim(); return <String>[name, value]; }).whereType<List<String>>(), key: (dynamic key) { final List<String> pair = key as List<String>; return pair[0]; }, value: (dynamic value) { final List<String> pair = value as List<String>; return pair[1]; }, ); return Response( 'test-report-id', 200, ); }); static int sendCalls = 0; } class CrashingCrashReportSender extends MockClient { CrashingCrashReportSender(Exception exception) : super((Request request) async { throw exception; }); } class FakeDoctorText extends Fake implements DoctorText { FakeDoctorText(String text, String piiStrippedText) : _text = text, _piiStrippedText = piiStrippedText; @override Future<String> get text async => _text; final String _text; @override Future<String> get piiStrippedText async => _piiStrippedText; final String _piiStrippedText; }