crash_reporting_test.dart 12.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:convert';
6

7
import 'package:file/file.dart';
8 9
import 'package:file/memory.dart';
import 'package:flutter_tools/src/base/io.dart';
10 11
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/os.dart';
12
import 'package:flutter_tools/src/base/platform.dart';
13
import 'package:flutter_tools/src/doctor.dart';
14
import 'package:flutter_tools/src/project.dart';
15
import 'package:flutter_tools/src/reporting/crash_reporting.dart';
16 17 18
import 'package:flutter_tools/src/reporting/reporting.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
19
import 'package:test/fake.dart';
20

21
import '../src/common.dart';
22
import '../src/fake_process_manager.dart';
23 24

void main() {
25 26 27 28 29 30
  late BufferLogger logger;
  late FileSystem fs;
  late TestUsage testUsage;
  late Platform platform;
  late OperatingSystemUtils operatingSystemUtils;
  late StackTrace stackTrace;
31 32 33

  setUp(() async {
    logger = BufferLogger.test();
34
    fs = MemoryFileSystem.test();
35
    testUsage = TestUsage();
36

37
    platform = FakePlatform(environment: <String, String>{});
38
    operatingSystemUtils = OperatingSystemUtils(
39
      fileSystem: fs,
40 41 42 43
      logger: logger,
      platform: platform,
      processManager: FakeProcessManager.any(),
    );
44

45
    MockCrashReportSender.sendCalls = 0;
46 47 48
    stackTrace = StackTrace.fromString('''
#0      _File.open.<anonymous closure> (dart:io/file_impl.dart:366:9)
#1      _rootRunUnary (dart:async/zone.dart:1141:38)''');
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
  });

  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',
      },
    ));
66 67 68 69 70 71 72 73 74
    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');
75 76 77 78 79

    expect(logger.traceText, contains('Sending crash report to Google.'));
    expect(logger.traceText, contains('Crash report sent (report ID: test-report-id)'));
  }

80
  testWithoutContext('CrashReporter.informUser provides basic instructions without PII', () async {
81 82 83 84 85 86 87 88 89 90 91 92 93
    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,
94 95 96
        // Spaces are URL query encoded in the output, make it one word to make this test simpler.
        doctorText: FakeDoctorText('Ignored', 'NoPIIFakeDoctorText'),
      ),
97 98 99
      file,
    );

100 101
    expect(logger.statusText, contains('NoPIIFakeDoctorText'));
    expect(logger.statusText, isNot(contains('Ignored')));
102
    expect(logger.statusText, contains('https://github.com/flutter/flutter/issues/new'));
103
    expect(logger.errorText, contains('A crash report has been written to ${file.path}.'));
104 105
  });

106
  testWithoutContext('suppress analytics', () async {
107
    testUsage.suppressAnalytics = true;
108 109 110

    final CrashReportSender crashReportSender = CrashReportSender(
      client: CrashingCrashReportSender(const SocketException('no internets')),
111
      usage: testUsage,
112 113 114 115 116 117 118
      platform: platform,
      logger: logger,
      operatingSystemUtils: operatingSystemUtils,
    );

    await crashReportSender.sendReport(
      error: StateError('Test bad state error'),
119
      stackTrace: stackTrace,
120 121 122 123 124 125
      getFlutterVersion: () => 'test-version',
      command: 'crash',
    );

    expect(logger.traceText, isEmpty);
  });
126

127 128
  group('allow analytics', () {
    setUp(() async {
129
      testUsage.suppressAnalytics = false;
130 131
    });

132
    testWithoutContext('should send crash reports', () async {
133
      final RequestInfo requestInfo = RequestInfo();
134

135 136
      final CrashReportSender crashReportSender = CrashReportSender(
        client: MockCrashReportSender(requestInfo),
137
        usage: testUsage,
138 139 140 141 142 143 144
        platform: platform,
        logger: logger,
        operatingSystemUtils: operatingSystemUtils,
      );

      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
145
        stackTrace: stackTrace,
146 147
        getFlutterVersion: () => 'test-version',
        command: 'crash',
148 149
      );

150 151
      await verifyCrashReportSent(requestInfo);
    });
152

153 154 155
    testWithoutContext('should print an explanatory message when there is a SocketException', () async {
      final CrashReportSender crashReportSender = CrashReportSender(
        client: CrashingCrashReportSender(const SocketException('no internets')),
156
        usage: testUsage,
157 158 159 160
        platform: platform,
        logger: logger,
        operatingSystemUtils: operatingSystemUtils,
      );
161

162 163
      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
164
        stackTrace: stackTrace,
165 166 167
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );
168

169
      expect(logger.errorText, contains('Failed to send crash report due to a network error'));
170 171
    });

172 173 174
    testWithoutContext('should print an explanatory message when there is an HttpException', () async {
      final CrashReportSender crashReportSender = CrashReportSender(
        client: CrashingCrashReportSender(const HttpException('no internets')),
175
        usage: testUsage,
176 177 178 179
        platform: platform,
        logger: logger,
        operatingSystemUtils: operatingSystemUtils,
      );
180

181 182
      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
183
        stackTrace: stackTrace,
184 185 186
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );
187

188
      expect(logger.errorText, contains('Failed to send crash report due to a network error'));
189 190
    });

191 192 193
    testWithoutContext('should print an explanatory message when there is a ClientException', () async {
      final CrashReportSender crashReportSender = CrashReportSender(
        client: CrashingCrashReportSender(const HttpException('no internets')),
194
        usage: testUsage,
195 196 197 198 199 200 201
        platform: platform,
        logger: logger,
        operatingSystemUtils: operatingSystemUtils,
      );

      await crashReportSender.sendReport(
        error: ClientException('Test bad state error'),
202
        stackTrace: stackTrace,
203 204 205 206 207 208 209
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );

      expect(logger.errorText, contains('Failed to send crash report due to a network error'));
    });

210
    testWithoutContext('should send only one crash report when sent many times', () async {
211 212
      final RequestInfo requestInfo = RequestInfo();

213 214
      final CrashReportSender crashReportSender = CrashReportSender(
        client: MockCrashReportSender(requestInfo),
215
        usage: testUsage,
216 217 218 219
        platform: platform,
        logger: logger,
        operatingSystemUtils: operatingSystemUtils,
      );
220

221 222
      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
223
        stackTrace: stackTrace,
224 225 226
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );
227

228 229
      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
230
        stackTrace: stackTrace,
231 232 233 234 235 236
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );

      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
237
        stackTrace: stackTrace,
238 239 240 241 242 243
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );

      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
244
        stackTrace: stackTrace,
245 246 247
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );
248 249 250 251 252

      expect(MockCrashReportSender.sendCalls, 1);
      await verifyCrashReportSent(requestInfo, crashes: 4);
    });

253
    testWithoutContext('should not send a crash report if on a user-branch', () async {
254 255
      String? method;
      Uri? uri;
256

257
      final MockClient mockClient = MockClient((Request request) async {
258 259 260 261 262 263 264
        method = request.method;
        uri = request.url;

        return Response(
          'test-report-id',
          200,
        );
265
      });
266

267 268
      final CrashReportSender crashReportSender = CrashReportSender(
        client: mockClient,
269
        usage: testUsage,
270 271 272
        platform: platform,
        logger: logger,
        operatingSystemUtils: operatingSystemUtils,
273 274
      );

275 276
      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
277
        stackTrace: stackTrace,
278 279 280
        getFlutterVersion: () => '[user-branch]/v1.2.3',
        command: 'crash',
      );
281 282 283 284 285

      // Verify that the report wasn't sent
      expect(method, null);
      expect(uri, null);

286
      expect(logger.traceText, isNot(contains('Crash report sent')));
287 288
    });

289
    testWithoutContext('can override base URL', () async {
290
      Uri? uri;
291
      final MockClient mockClient = MockClient((Request request) async {
292
        uri = request.url;
293
        return Response('test-report-id', 200);
294 295 296 297 298 299 300 301 302
      });

      final Platform environmentPlatform = FakePlatform(
        environment: <String, String>{
          'HOME': '/',
          'FLUTTER_CRASH_SERVER_BASE_URL': 'https://localhost:12345/fake_server',
        },
        script: Uri(scheme: 'data'),
      );
303

304 305
      final CrashReportSender crashReportSender = CrashReportSender(
        client: mockClient,
306
        usage: testUsage,
307 308 309
        platform: environmentPlatform,
        logger: logger,
        operatingSystemUtils: operatingSystemUtils,
310 311
      );

312 313
      await crashReportSender.sendReport(
        error: StateError('Test bad state error'),
314
        stackTrace: stackTrace,
315 316 317
        getFlutterVersion: () => 'test-version',
        command: 'crash',
      );
318 319 320

      // Verify that we sent the crash report.
      expect(uri, isNotNull);
321
      expect(uri, Uri(
322 323 324 325 326 327
        scheme: 'https',
        host: 'localhost',
        port: 12345,
        path: '/fake_server',
        queryParameters: <String, String>{
          'product': 'Flutter_Tools',
328
          'version': 'test-version',
329 330 331
        },
      ));
    });
332 333 334
  });
}

335
class RequestInfo {
336 337 338
  String? method;
  Uri? uri;
  Map<String, String>? fields;
339 340 341 342
}

class MockCrashReportSender extends MockClient {
  MockCrashReportSender(RequestInfo crashInfo) : super((Request request) async {
343 344 345 346 347
    MockCrashReportSender.sendCalls++;
    crashInfo.method = request.method;
    crashInfo.uri = request.url;

    // A very ad-hoc multipart request parser. Good enough for this test.
348 349
    String? boundary = request.headers['Content-Type'];
    boundary = boundary?.substring(boundary.indexOf('boundary=') + 9);
350 351 352
    crashInfo.fields = Map<String, String>.fromIterable(
      utf8.decode(request.bodyBytes)
        .split('--$boundary')
353 354
        .map<List<String>?>((String part) {
        final Match? nameMatch = RegExp(r'name="(.*)"').firstMatch(part);
355 356 357
        if (nameMatch == null) {
          return null;
        }
358
        final String name = nameMatch[1]!;
359 360
        final String value = part.split('\n').skip(2).join('\n').trim();
        return <String>[name, value];
361
      }).whereType<List<String>>(),
362
      key: (dynamic key) {
363
        final List<String> pair = key as List<String>;
364 365 366
        return pair[0];
      },
      value: (dynamic value) {
367
        final List<String> pair = value as List<String>;
368 369 370 371 372 373 374 375 376
        return pair[1];
      },
    );

    return Response(
      'test-report-id',
      200,
    );
  });
377 378

  static int sendCalls = 0;
379 380
}

381
class CrashingCrashReportSender extends MockClient {
382
  CrashingCrashReportSender(Exception exception) : super((Request request) async {
383 384 385
    throw exception;
  });
}
386 387 388 389 390 391 392 393 394 395 396 397 398

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;
}