| // Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file |
| // for details. 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:io' as io; |
| |
| import 'package:file/file.dart'; |
| import 'package:file/memory.dart'; |
| import 'package:test/test.dart'; |
| |
| import 'package:unified_analytics/src/constants.dart'; |
| import 'package:unified_analytics/src/enums.dart'; |
| import 'package:unified_analytics/unified_analytics.dart'; |
| |
| void main() { |
| late MemoryFileSystem fs; |
| late Directory home; |
| late FakeAnalytics initializationAnalytics; |
| late FakeAnalytics analytics; |
| late File sessionFile; |
| late File logFile; |
| |
| const homeDirName = 'home'; |
| const initialTool = DashTool.flutterTool; |
| const toolsMessageVersion = 1; |
| const toolsMessage = 'toolsMessage'; |
| const flutterChannel = 'flutterChannel'; |
| const flutterVersion = 'flutterVersion'; |
| const dartVersion = 'dartVersion'; |
| const platform = DevicePlatform.macos; |
| const clientIde = 'VSCode'; |
| |
| final testEvent = Event.codeSizeAnalysis(platform: 'platform'); |
| |
| setUp(() { |
| // Setup the filesystem with the home directory |
| final fsStyle = |
| io.Platform.isWindows ? FileSystemStyle.windows : FileSystemStyle.posix; |
| fs = MemoryFileSystem.test(style: fsStyle); |
| home = fs.directory(homeDirName); |
| |
| // This is the first analytics instance that will be used to demonstrate |
| // that events will not be sent with the first run of analytics |
| initializationAnalytics = Analytics.fake( |
| tool: initialTool, |
| homeDirectory: home, |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| fs: fs, |
| platform: platform, |
| ); |
| expect(initializationAnalytics.shouldShowMessage, true); |
| initializationAnalytics.clientShowedMessage(); |
| expect(initializationAnalytics.shouldShowMessage, false); |
| |
| // The main analytics instance, other instances can be spawned within tests |
| // to test how to instances running together work |
| // |
| // This instance should have the same parameters as the one above for |
| // [initializationAnalytics] |
| analytics = Analytics.fake( |
| tool: initialTool, |
| homeDirectory: home, |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| fs: fs, |
| platform: platform, |
| clientIde: clientIde, |
| ); |
| analytics.clientShowedMessage(); |
| |
| // The files that should have been generated that will be used for tests |
| sessionFile = |
| home.childDirectory(kDartToolDirectoryName).childFile(kSessionFileName); |
| logFile = |
| home.childDirectory(kDartToolDirectoryName).childFile(kLogFileName); |
| }); |
| |
| group('Session handler:', () { |
| test('no error when opted out already and opting in', () async { |
| // When we opt out from an analytics instance, we clear the contents of |
| // session file, as required by the privacy document. When creating a |
| // second instance of [Analytics], it should not detect that the file is |
| // empty and recreate it, it should remain opted out and no error event |
| // should have been sent |
| await analytics.setTelemetry(false); |
| expect(analytics.telemetryEnabled, false); |
| expect(sessionFile.readAsStringSync(), isEmpty); |
| |
| final secondAnalytics = Analytics.fake( |
| tool: initialTool, |
| homeDirectory: home, |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| fs: fs, |
| platform: platform, |
| clientIde: clientIde, |
| ); |
| expect(sessionFile.readAsStringSync(), isEmpty); |
| expect(secondAnalytics.telemetryEnabled, false); |
| |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| isEmpty, |
| ); |
| expect( |
| secondAnalytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| isEmpty, |
| ); |
| |
| await secondAnalytics.setTelemetry(true); |
| expect(sessionFile.readAsStringSync(), isNotEmpty, |
| reason: 'Toggling telemetry should bring back the session data'); |
| }); |
| test('only sends one event for FormatException', () { |
| // Begin with the session file empty, it should recreate the file |
| // and send an error event |
| sessionFile.writeAsStringSync(''); |
| expect(sessionFile.readAsStringSync(), isEmpty); |
| analytics.send(testEvent); |
| expect(sessionFile.readAsStringSync(), isNotEmpty); |
| |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1)); |
| |
| // Making the file empty again and sending an event should not send |
| // an additional event |
| sessionFile.writeAsStringSync(''); |
| expect(sessionFile.readAsStringSync(), isEmpty); |
| analytics.send(testEvent); |
| expect(sessionFile.readAsStringSync(), isNotEmpty); |
| |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1), |
| reason: 'We should not have added a new error event'); |
| }); |
| |
| test('only sends one event for FileSystemException', () { |
| // Deleting the session file should cause the file system exception and |
| // sending a new event should log the error the first time and recreate |
| // the file. If we delete the file again and attempt to send an event, |
| // the session file should get recreated without sending a second error. |
| sessionFile.deleteSync(); |
| expect(sessionFile.existsSync(), isFalse); |
| analytics.send(testEvent); |
| |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1), |
| ); |
| expect(sessionFile.existsSync(), isTrue); |
| expect(sessionFile.readAsStringSync(), isNotEmpty); |
| |
| // Remove the file again and send an event |
| sessionFile.deleteSync(); |
| expect(sessionFile.existsSync(), isFalse); |
| analytics.send(testEvent); |
| |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1), |
| reason: 'Only the first error event should exist', |
| ); |
| expect(sessionFile.existsSync(), isTrue); |
| expect(sessionFile.readAsStringSync(), isNotEmpty); |
| }); |
| |
| test('sends two unique errors', () { |
| // Begin with the session file empty, it should recreate the file |
| // and send an error event |
| sessionFile.writeAsStringSync(''); |
| expect(sessionFile.readAsStringSync(), isEmpty); |
| analytics.send(testEvent); |
| expect(sessionFile.readAsStringSync(), isNotEmpty); |
| |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1)); |
| |
| // Deleting the file now before sending an additional event should |
| // cause a different test error |
| sessionFile.deleteSync(); |
| expect(sessionFile.existsSync(), isFalse); |
| |
| analytics.send(testEvent); |
| expect(sessionFile.readAsStringSync(), isNotEmpty); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(2)); |
| expect(analytics.sentEvents, hasLength(4)); |
| |
| sessionFile.deleteSync(); |
| expect(sessionFile.existsSync(), isFalse); |
| |
| analytics.send(testEvent); |
| expect(sessionFile.readAsStringSync(), isNotEmpty); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(2)); |
| }); |
| }); |
| |
| group('Log handler:', () { |
| test('only sends one event for FormatException', () { |
| expect(logFile.existsSync(), isTrue); |
| |
| // Write invalid lines to the log file to have a FormatException |
| // thrown when trying to parse the log file |
| logFile.writeAsStringSync(''' |
| {{} |
| {{} |
| '''); |
| |
| // Send one event so that the logFileStats method returns a valid value |
| analytics.send(testEvent); |
| expect(analytics.sentEvents, hasLength(1)); |
| expect(logFile.readAsLinesSync(), hasLength(3)); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| isEmpty, |
| ); |
| |
| // This call below will cause a FormatException while parsing the log file |
| final logFileStats = analytics.logFileStats(); |
| expect(logFileStats, isNotNull); |
| expect(logFileStats!.recordCount, 1, |
| reason: 'The error event is not counted'); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1), |
| ); |
| expect(logFile.readAsLinesSync(), hasLength(4)); |
| }); |
| |
| test('only sends one event for TypeError', () { |
| expect(logFile.existsSync(), isTrue); |
| // Write valid json but have one of the types wrong for |
| // the keys so that we throw a TypeError while casting the values |
| // |
| // In the json below, we have made the session id value a string when |
| // it should be an integer |
| logFile.writeAsStringSync(''' |
| {"client_id":"fcd6c0d5-6582-4c36-b09e-3ecedee9145c","events":[{"name":"command_usage_values","params":{"workflow":"doctor","commandHasTerminal":true}}],"user_properties":{"session_id":{"value":"this should be a string"},"flutter_channel":{"value":"master"},"host":{"value":"macOS"},"flutter_version":{"value":"3.20.0-2.0.pre.9"},"dart_version":{"value":"3.4.0 (build 3.4.0-99.0.dev)"},"analytics_pkg_version":{"value":"5.8.1"},"tool":{"value":"flutter-tool"},"local_time":{"value":"2024-02-07 15:46:19.920784 -0500"},"host_os_version":{"value":"Version 14.3 (Build 23D56)"},"locale":{"value":"en"},"client_ide":{"value":null},"enabled_features":{"value":"enable-native-assets"}}} |
| '''); |
| expect(logFile.readAsLinesSync(), hasLength(1)); |
| |
| // Send the test event so that the LogFileStats object is not null |
| analytics.send(testEvent); |
| |
| final logFileStats = analytics.logFileStats(); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1), |
| ); |
| expect(logFileStats, isNotNull); |
| expect(logFileStats!.recordCount, 1); |
| }); |
| |
| test('sends two unique errors', () { |
| expect(logFile.existsSync(), isTrue); |
| |
| // Write invalid lines to the log file to have a FormatException |
| // thrown when trying to parse the log file |
| logFile.writeAsStringSync(''' |
| {{} |
| {{} |
| '''); |
| |
| // Send one event so that the logFileStats method returns a valid value |
| analytics.send(testEvent); |
| expect(analytics.sentEvents, hasLength(1)); |
| expect(logFile.readAsLinesSync(), hasLength(3)); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| isEmpty, |
| ); |
| |
| // This will cause the first error |
| analytics.logFileStats(); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(1), |
| ); |
| |
| // Overwrite the contents of the log file now to include something that |
| // will cause a TypeError by changing the expected value for session id |
| // from integer to a string |
| logFile.writeAsStringSync(''' |
| {"client_id":"fcd6c0d5-6582-4c36-b09e-3ecedee9145c","events":[{"name":"command_usage_values","params":{"workflow":"doctor","commandHasTerminal":true}}],"user_properties":{"session_id":{"value":"this should be a string"},"flutter_channel":{"value":"master"},"host":{"value":"macOS"},"flutter_version":{"value":"3.20.0-2.0.pre.9"},"dart_version":{"value":"3.4.0 (build 3.4.0-99.0.dev)"},"analytics_pkg_version":{"value":"5.8.1"},"tool":{"value":"flutter-tool"},"local_time":{"value":"2024-02-07 15:46:19.920784 -0500"},"host_os_version":{"value":"Version 14.3 (Build 23D56)"},"locale":{"value":"en"},"client_ide":{"value":null},"enabled_features":{"value":"enable-native-assets"}}} |
| '''); |
| expect(logFile.readAsLinesSync(), hasLength(1)); |
| |
| // This will cause the second error |
| analytics.logFileStats(); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(2), |
| ); |
| |
| // Attempting to cause the same error won't send another error event |
| analytics.logFileStats(); |
| analytics.sendPendingErrorEvents(); |
| expect( |
| analytics.sentEvents.where( |
| (element) => element.eventName == DashEvent.analyticsException), |
| hasLength(2), |
| ); |
| }); |
| }); |
| } |