| // 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), | |
| ); | |
| }); | |
| }); | |
| } |