| // 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:convert'; |
| import 'dart:io' as io; |
| import 'dart:math'; |
| |
| import 'package:clock/clock.dart'; |
| import 'package:unified_analytics/unified_analytics.dart'; |
| import 'package:unified_analytics/src/config_handler.dart'; |
| import 'package:unified_analytics/src/constants.dart'; |
| import 'package:unified_analytics/src/session.dart'; |
| import 'package:unified_analytics/src/user_property.dart'; |
| import 'package:unified_analytics/src/utils.dart'; |
| import 'package:file/file.dart'; |
| import 'package:file/memory.dart'; |
| import 'package:test/test.dart'; |
| import 'package:yaml/yaml.dart'; |
| |
| void main() { |
| late FileSystem fs; |
| late Directory home; |
| late Directory dartToolDirectory; |
| late Analytics initializationAnalytics; |
| late Analytics analytics; |
| late File clientIdFile; |
| late File sessionFile; |
| late File configFile; |
| late File logFile; |
| late UserProperty userProperty; |
| |
| const String homeDirName = 'home'; |
| const String initialToolName = 'initial_tool'; |
| const String secondTool = 'newTool'; |
| const String measurementId = 'measurementId'; |
| const String apiSecret = 'apiSecret'; |
| const int toolsMessageVersion = 1; |
| const String toolsMessage = 'toolsMessage'; |
| const String flutterChannel = 'flutterChannel'; |
| const String flutterVersion = 'flutterVersion'; |
| const String dartVersion = 'dartVersion'; |
| const DevicePlatform platform = DevicePlatform.macos; |
| |
| setUp(() { |
| // Setup the filesystem with the home directory |
| final FileSystemStyle fsStyle = |
| io.Platform.isWindows ? FileSystemStyle.windows : FileSystemStyle.posix; |
| fs = MemoryFileSystem.test(style: fsStyle); |
| home = fs.directory(homeDirName); |
| dartToolDirectory = home.childDirectory(kDartToolDirectoryName); |
| |
| // 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.test( |
| tool: initialToolName, |
| homeDirectory: home, |
| measurementId: measurementId, |
| apiSecret: apiSecret, |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| fs: fs, |
| platform: platform, |
| ); |
| |
| // 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.test( |
| tool: initialToolName, |
| homeDirectory: home, |
| measurementId: measurementId, |
| apiSecret: apiSecret, |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| fs: fs, |
| platform: platform, |
| ); |
| |
| // The 3 files that should have been generated |
| clientIdFile = home |
| .childDirectory(kDartToolDirectoryName) |
| .childFile(kClientIdFileName); |
| sessionFile = |
| home.childDirectory(kDartToolDirectoryName).childFile(kSessionFileName); |
| configFile = |
| home.childDirectory(kDartToolDirectoryName).childFile(kConfigFileName); |
| logFile = |
| home.childDirectory(kDartToolDirectoryName).childFile(kLogFileName); |
| |
| // Create the user property object that is also |
| // created within analytics for testing |
| userProperty = UserProperty( |
| session: Session(homeDirectory: home, fs: fs), |
| flutterChannel: flutterChannel, |
| host: platform.label, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| tool: initialToolName, |
| ); |
| }); |
| |
| test('Initializer properly sets up on first run', () { |
| expect(dartToolDirectory.existsSync(), true, |
| reason: 'The directory should have been created'); |
| expect(clientIdFile.existsSync(), true, |
| reason: 'The $kClientIdFileName file was not found'); |
| expect(sessionFile.existsSync(), true, |
| reason: 'The $kSessionFileName file was not found'); |
| expect(configFile.existsSync(), true, |
| reason: 'The $kConfigFileName was not found'); |
| expect(logFile.existsSync(), true, |
| reason: 'The $kLogFileName file was not found'); |
| expect(dartToolDirectory.listSync().length, equals(4), |
| reason: |
| 'There should only be 4 files in the $kDartToolDirectoryName directory'); |
| expect(initializationAnalytics.shouldShowMessage, true, |
| reason: 'For the first run, analytics should default to being enabled'); |
| expect(configFile.readAsLinesSync().length, |
| kConfigString.split('\n').length + 1, |
| reason: 'The number of lines should equal lines in constant value + 1 ' |
| 'for the initialized tool'); |
| }); |
| |
| test('New tool is successfully added to config file', () { |
| // Create a new instance of the analytics class with the new tool |
| final Analytics secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: 'ey-test-channel', |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| |
| expect(secondAnalytics.parsedTools.length, equals(2), |
| reason: 'There should be only 2 tools that have ' |
| 'been parsed into the config file'); |
| expect(secondAnalytics.parsedTools.containsKey(initialToolName), true, |
| reason: 'The first tool: $initialToolName should be in the map'); |
| expect(secondAnalytics.parsedTools.containsKey(secondTool), true, |
| reason: 'The second tool: $secondAnalytics should be in the map'); |
| expect(configFile.readAsStringSync().startsWith(kConfigString), true, |
| reason: |
| 'The config file should have the same message from the constants file'); |
| }); |
| |
| test('First time analytics run will not send events, second time will', () { |
| // Send an event with the first analytics class; this should result |
| // in no logs in the log file which keeps track of all the events |
| // that have been sent |
| initializationAnalytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| initializationAnalytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| // Use the second instance of analytics defined in setUp() to send the actual |
| // events to simulate the second time the tool ran |
| analytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| expect(logFile.readAsLinesSync().length, 1, |
| reason: 'The second analytics instance should have logged an event'); |
| }); |
| |
| test('Toggling telemetry boolean through Analytics class api', () async { |
| expect(analytics.telemetryEnabled, true, |
| reason: 'Telemetry should be enabled by default ' |
| 'when initialized for the first time'); |
| // Use the API to disable analytics |
| expect(logFile.readAsLinesSync().length, 0); |
| await analytics.setTelemetry(false); |
| expect(analytics.telemetryEnabled, false, |
| reason: 'Analytics telemetry should be disabled'); |
| expect(logFile.readAsLinesSync().length, 1, |
| reason: 'One event should have been logged for disabling analytics'); |
| |
| // Extract the last log item to check for the keys |
| Map<String, Object?> lastLogItem = |
| jsonDecode(logFile.readAsLinesSync().last); |
| expect((lastLogItem['events'] as List).last['name'], |
| 'analytics_collection_enabled', |
| reason: 'Check on event name'); |
| expect((lastLogItem['events'] as List).last['params']['status'], false, |
| reason: 'Status should be false'); |
| |
| // Toggle it back to being enabled |
| await analytics.setTelemetry(true); |
| expect(analytics.telemetryEnabled, true, |
| reason: 'Analytics telemetry should be enabled'); |
| expect(logFile.readAsLinesSync().length, 2, |
| reason: 'Second event should have been logged toggling ' |
| 'analytics back on'); |
| |
| // Extract the last log item to check for the keys |
| lastLogItem = jsonDecode(logFile.readAsLinesSync().last); |
| expect((lastLogItem['events'] as List).last['name'], |
| 'analytics_collection_enabled', |
| reason: 'Check on event name'); |
| expect((lastLogItem['events'] as List).last['params']['status'], true, |
| reason: 'Status should be false'); |
| }); |
| |
| test( |
| 'Telemetry has been disabled by one ' |
| 'tool and second tool correctly shows telemetry is disabled', () async { |
| expect(analytics.telemetryEnabled, true, |
| reason: 'Analytics telemetry should be enabled on initialization'); |
| // Use the API to disable analytics |
| await analytics.setTelemetry(false); |
| expect(analytics.telemetryEnabled, false, |
| reason: 'Analytics telemetry should be disabled'); |
| |
| // Initialize a second analytics class, which simulates a second tool |
| // Create a new instance of the analytics class with the new tool |
| final Analytics secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: 'ey-test-channel', |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| |
| expect(secondAnalytics.telemetryEnabled, false, |
| reason: 'Analytics telemetry should be disabled by the first class ' |
| 'and the second class should show telemetry is disabled'); |
| }); |
| |
| test( |
| 'Two concurrent instances are running ' |
| 'and reflect an accurate up to date telemetry status', () async { |
| // Initialize a second analytics class, which simulates a second tool |
| final Analytics secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: 'ey-test-channel', |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| |
| expect(analytics.telemetryEnabled, true, |
| reason: 'Telemetry should be enabled on initialization for ' |
| 'first analytics instance'); |
| expect(secondAnalytics.telemetryEnabled, true, |
| reason: 'Telemetry should be enabled on initialization for ' |
| 'second analytics instance'); |
| |
| // Use the API to disable analytics on the first instance |
| await analytics.setTelemetry(false); |
| expect(analytics.telemetryEnabled, false, |
| reason: 'Analytics telemetry should be disabled on first instance'); |
| |
| expect(secondAnalytics.telemetryEnabled, false, |
| reason: 'Analytics telemetry should be disabled by the first class ' |
| 'and the second class should show telemetry is disabled' |
| ' by checking the timestamp on the config file'); |
| }); |
| |
| test('New line character is added if missing', () { |
| String currentConfigFileString; |
| |
| expect(configFile.readAsStringSync().endsWith('\n'), true, |
| reason: 'When initialized, the tool should correctly ' |
| 'add a trailing new line character'); |
| |
| // Remove the trailing new line character before initializing a second |
| // analytics class; the new class should correctly format the config file |
| currentConfigFileString = configFile.readAsStringSync(); |
| currentConfigFileString = currentConfigFileString.substring( |
| 0, currentConfigFileString.length - 1); |
| |
| // Write back out to the config file to be processed again |
| configFile.writeAsStringSync(currentConfigFileString); |
| |
| expect(configFile.readAsStringSync().endsWith('\n'), false, |
| reason: 'The trailing new line should be missing'); |
| |
| // Initialize a second analytics class, which simulates a second tool |
| // which should correct the missing trailing new line character |
| final Analytics secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: 'ey-test-channel', |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| expect(secondAnalytics.telemetryEnabled, true); |
| |
| expect(configFile.readAsStringSync().endsWith('\n'), true, |
| reason: 'The second analytics class will correct ' |
| 'the missing new line character'); |
| }); |
| |
| test('Incrementing the version for a tool is successful', () { |
| expect(analytics.parsedTools[initialToolName]?.versionNumber, |
| toolsMessageVersion, |
| reason: 'On initialization, the first version number should ' |
| 'be what is set in the setup method'); |
| |
| // Initialize a second analytics class for the same tool as |
| // the first analytics instance except with a newer version for |
| // the tools message and version |
| final Analytics secondAnalytics = Analytics.test( |
| tool: initialToolName, |
| homeDirectory: home, |
| measurementId: measurementId, |
| apiSecret: apiSecret, |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion + 1, |
| toolsMessage: toolsMessage, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| fs: fs, |
| platform: platform, |
| ); |
| |
| expect(secondAnalytics.parsedTools[initialToolName]?.versionNumber, |
| toolsMessageVersion + 1, |
| reason: |
| 'The second analytics instance should have incremented the version'); |
| }); |
| |
| test( |
| 'Config file resets when there is not exactly one match for the reporting flag', |
| () async { |
| // Write to the config file a string that is not formatted correctly |
| // (ie. there is more than one match for the reporting flag) |
| configFile.writeAsStringSync(''' |
| # INTRODUCTION |
| # |
| # This is the Flutter and Dart telemetry reporting |
| # configuration file. |
| # |
| # Lines starting with a #" are documentation that |
| # the tools maintain automatically. |
| # |
| # All other lines are configuration lines. They have |
| # the form "name=value". If multiple lines contain |
| # the same configuration name with different values, |
| # the parser will default to a conservative value. |
| |
| # DISABLING TELEMETRY REPORTING |
| # |
| # To disable telemetry reporting, set "reporting" to |
| # the value "0" and to enable, set to "1": |
| reporting=1 |
| reporting=1 |
| |
| # NOTIFICATIONS |
| # |
| # Each tool records when it last informed the user about |
| # analytics reporting and the privacy policy. |
| # |
| # The following tools have so far read this file: |
| # |
| # dart-tools (Dart CLI developer tool) |
| # devtools (DevTools debugging and performance tools) |
| # flutter-tools (Flutter CLI developer tool) |
| # |
| # For each one, the file may contain a configuration line |
| # where the name is the code in the list above, e.g. "dart-tool", |
| # and the value is a date in the form YYYY-MM-DD, a comma, and |
| # a number representing the version of the message that was |
| # displayed.'''); |
| |
| // Disable telemetry which should result in a reset of the config file |
| await analytics.setTelemetry(false); |
| |
| expect(configFile.readAsStringSync().startsWith(kConfigString), true, |
| reason: 'The tool should have reset the config file ' |
| 'because it was not formatted correctly'); |
| }); |
| |
| test('Config file resets when there is not exactly one match for the tool', |
| () { |
| // Write to the config file a string that is not formatted correctly |
| // (ie. there is more than one match for the reporting flag) |
| configFile.writeAsStringSync(''' |
| # INTRODUCTION |
| # |
| # This is the Flutter and Dart telemetry reporting |
| # configuration file. |
| # |
| # Lines starting with a #" are documentation that |
| # the tools maintain automatically. |
| # |
| # All other lines are configuration lines. They have |
| # the form "name=value". If multiple lines contain |
| # the same configuration name with different values, |
| # the parser will default to a conservative value. |
| |
| # DISABLING TELEMETRY REPORTING |
| # |
| # To disable telemetry reporting, set "reporting" to |
| # the value "0" and to enable, set to "1": |
| reporting=1 |
| |
| # NOTIFICATIONS |
| # |
| # Each tool records when it last informed the user about |
| # analytics reporting and the privacy policy. |
| # |
| # The following tools have so far read this file: |
| # |
| # dart-tools (Dart CLI developer tool) |
| # devtools (DevTools debugging and performance tools) |
| # flutter-tools (Flutter CLI developer tool) |
| # |
| # For each one, the file may contain a configuration line |
| # where the name is the code in the list above, e.g. "dart-tool", |
| # and the value is a date in the form YYYY-MM-DD, a comma, and |
| # a number representing the version of the message that was |
| # displayed. |
| $initialToolName=${ConfigHandler.dateStamp},$toolsMessageVersion |
| $initialToolName=${ConfigHandler.dateStamp},$toolsMessageVersion |
| '''); |
| |
| // Initialize a second analytics class for the same tool as |
| // the first analytics instance except with a newer version for |
| // the tools message and version |
| // |
| // This second instance should reset the config file when it goes |
| // to increment the version in the file |
| final Analytics secondAnalytics = Analytics.test( |
| tool: initialToolName, |
| homeDirectory: home, |
| measurementId: measurementId, |
| apiSecret: apiSecret, |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion + 1, |
| toolsMessage: toolsMessage, |
| flutterVersion: flutterVersion, |
| dartVersion: dartVersion, |
| fs: fs, |
| platform: platform, |
| ); |
| |
| expect( |
| configFile.readAsStringSync().endsWith( |
| '# displayed.\n$initialToolName=${ConfigHandler.dateStamp},${toolsMessageVersion + 1}\n'), |
| true, |
| reason: 'The config file ends with the correctly formatted ending ' |
| 'after removing the duplicate lines for a given tool', |
| ); |
| expect( |
| secondAnalytics.parsedTools[initialToolName]?.versionNumber, |
| toolsMessageVersion + 1, |
| reason: 'The new version should have been incremented', |
| ); |
| }); |
| |
| test('Check that UserProperty class has all the necessary keys', () { |
| const List<String> userPropertyKeys = <String>[ |
| 'session_id', |
| 'flutter_channel', |
| 'host', |
| 'flutter_version', |
| 'dart_version', |
| 'analytics_pkg_version', |
| 'tool', |
| 'local_time', |
| ]; |
| expect(analytics.userPropertyMap.keys.length, userPropertyKeys.length, |
| reason: 'There should only be ${userPropertyKeys.length} keys'); |
| for (String key in userPropertyKeys) { |
| expect(analytics.userPropertyMap.keys.contains(key), true, |
| reason: 'The $key variable is required'); |
| } |
| }); |
| |
| test('The minimum session duration should be at least 30 minutes', () { |
| expect(kSessionDurationMinutes < 30, false, |
| reason: 'Session is less than 30 minutes'); |
| }); |
| |
| test( |
| 'The session id stays the same when duration' |
| ' is less than the constraint', () { |
| // For this test, we will need control clock time so we will delete |
| // the [dartToolDirectory] and all of its contents and reconstruct a |
| // new [Analytics] instance at a specific time |
| dartToolDirectory.deleteSync(recursive: true); |
| expect(dartToolDirectory.existsSync(), false, |
| reason: 'The directory should have been cleared'); |
| |
| // Define the initial time to start |
| final DateTime start = DateTime(1995, 3, 3, 12, 0); |
| |
| // Set the clock to the start value defined above |
| withClock(Clock.fixed(start), () { |
| // This class will be constructed at a fixed time |
| final Analytics secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| |
| // Read the contents of the session file |
| final String sessionFileContents = sessionFile.readAsStringSync(); |
| final Map<String, dynamic> sessionObj = jsonDecode(sessionFileContents); |
| |
| expect(secondAnalytics.userPropertyMap['session_id']?['value'], |
| start.millisecondsSinceEpoch); |
| expect(sessionObj['last_ping'], start.millisecondsSinceEpoch); |
| }); |
| |
| // Add time to the start time that is less than the duration |
| final DateTime end = |
| start.add(Duration(minutes: kSessionDurationMinutes - 1)); |
| |
| // Use a new clock to ensure that the session id didn't change |
| withClock(Clock.fixed(end), () { |
| // A new instance will need to be created since the second |
| // instance in the previous block is scoped - this new instance |
| // should not reset the files generated by the second instance |
| final Analytics thirdAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| |
| // Calling the send event method will result in the session file |
| // getting updated but because we use the `Analytics.test()` constructor |
| // no events will be sent |
| thirdAnalytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| // Read the contents of the session file |
| final String sessionFileContents = sessionFile.readAsStringSync(); |
| final Map<String, dynamic> sessionObj = jsonDecode(sessionFileContents); |
| |
| expect(thirdAnalytics.userPropertyMap['session_id']?['value'], |
| start.millisecondsSinceEpoch, |
| reason: 'The session id should not have changed since it was made ' |
| 'within the duration'); |
| expect(sessionObj['last_ping'], end.millisecondsSinceEpoch, |
| reason: 'The last_ping value should have been updated'); |
| }); |
| }); |
| |
| test('The session id is refreshed once event is sent after duration', () { |
| // For this test, we will need control clock time so we will delete |
| // the [dartToolDirectory] and all of its contents and reconstruct a |
| // new [Analytics] instance at a specific time |
| dartToolDirectory.deleteSync(recursive: true); |
| expect(dartToolDirectory.existsSync(), false, |
| reason: 'The directory should have been cleared'); |
| |
| // Define the initial time to start |
| final DateTime start = DateTime(1995, 3, 3, 12, 0); |
| |
| // Set the clock to the start value defined above |
| withClock(Clock.fixed(start), () { |
| // This class will be constructed at a fixed time |
| final Analytics secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| |
| // Read the contents of the session file |
| final String sessionFileContents = sessionFile.readAsStringSync(); |
| final Map<String, dynamic> sessionObj = jsonDecode(sessionFileContents); |
| |
| expect(secondAnalytics.userPropertyMap['session_id']?['value'], |
| start.millisecondsSinceEpoch); |
| expect(sessionObj['last_ping'], start.millisecondsSinceEpoch); |
| |
| secondAnalytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| }); |
| |
| // Add time to the start time that is less than the duration |
| final DateTime end = |
| start.add(Duration(minutes: kSessionDurationMinutes + 1)); |
| |
| // Use a new clock to ensure that the session id didn't change |
| withClock(Clock.fixed(end), () { |
| // A new instance will need to be created since the second |
| // instance in the previous block is scoped - this new instance |
| // should not reset the files generated by the second instance |
| final Analytics thirdAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| |
| // Calling the send event method will result in the session file |
| // getting updated but because we use the `Analytics.test()` constructor |
| // no events will be sent |
| thirdAnalytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| // Read the contents of the session file |
| final String sessionFileContents = sessionFile.readAsStringSync(); |
| final Map<String, dynamic> sessionObj = jsonDecode(sessionFileContents); |
| |
| expect(thirdAnalytics.userPropertyMap['session_id']?['value'], |
| end.millisecondsSinceEpoch, |
| reason: 'The session id should have changed since it was made ' |
| 'outside the duration'); |
| expect(sessionObj['last_ping'], end.millisecondsSinceEpoch, |
| reason: 'The last_ping value should have been updated'); |
| }); |
| }); |
| |
| test('Validate the available enum types for DevicePlatform', () { |
| expect(DevicePlatform.values.length, 3, |
| reason: 'There should only be 3 supported device platforms'); |
| expect(DevicePlatform.values.contains(DevicePlatform.windows), true); |
| expect(DevicePlatform.values.contains(DevicePlatform.macos), true); |
| expect(DevicePlatform.values.contains(DevicePlatform.linux), true); |
| }); |
| |
| test('Validate the request body', () { |
| // Sample map for event data |
| final Map<String, dynamic> eventData = <String, dynamic>{ |
| 'time': 5, |
| 'command': 'run', |
| }; |
| |
| final Map<String, dynamic> body = generateRequestBody( |
| clientId: Uuid().generateV4(), |
| eventName: DashEvent.hotReloadTime, |
| eventData: eventData, |
| userProperty: userProperty, |
| ); |
| |
| // Checks for the top level keys |
| expect(body.containsKey('client_id'), true, |
| reason: '"client_id" is required at the top level'); |
| expect(body.containsKey('events'), true, |
| reason: '"events" is required at the top level'); |
| expect(body.containsKey('user_properties'), true, |
| reason: '"user_properties" is required at the top level'); |
| |
| // Regex for the client id |
| final RegExp clientIdPattern = RegExp( |
| r'^[0-9a-z]{8}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{12}$'); |
| |
| // Checks for the top level values |
| expect(body['client_id'].runtimeType, String, |
| reason: 'The client id must be a string'); |
| expect(clientIdPattern.hasMatch(body['client_id']), true, |
| reason: 'The client id is not properly formatted, ie ' |
| '46cc0ba6-f604-4fd9-aa2f-8a20beb24cd4'); |
| expect( |
| (body['events'][0] as Map<String, dynamic>).containsKey('name'), true, |
| reason: 'Each event in the events array needs a name'); |
| expect( |
| (body['events'][0] as Map<String, dynamic>).containsKey('params'), true, |
| reason: 'Each event in the events array needs a params key'); |
| }); |
| |
| test('Check that log file is correctly persisting events sent', () { |
| final int numberOfEvents = max((kLogFileLength * 0.1).floor(), 5); |
| |
| for (int i = 0; i < numberOfEvents; i++) { |
| analytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| } |
| |
| expect(logFile.readAsLinesSync().length, numberOfEvents, |
| reason: 'The number of events should be $numberOfEvents'); |
| |
| // Add the max number of events to confirm it does not exceed the max |
| for (int i = 0; i < kLogFileLength; i++) { |
| analytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| } |
| |
| expect(logFile.readAsLinesSync().length, kLogFileLength, |
| reason: 'The number of events should be capped at $kLogFileLength'); |
| }); |
| |
| test('Check the query on the log file works as expected', () { |
| // Define a new clock so that we can check the output of the |
| // log file stats method explicitly |
| final DateTime start = DateTime(1995, 3, 3, 12, 0); |
| final Clock firstClock = Clock.fixed(start); |
| |
| // Run with the simulated clock for the initial events |
| withClock(firstClock, () { |
| expect(analytics.logFileStats(), isNull, |
| reason: 'The result for the log file stats should be null when ' |
| 'there are no logs'); |
| analytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| final LogFileStats firstQuery = analytics.logFileStats()!; |
| expect(firstQuery.sessionCount, 1, |
| reason: |
| 'There should only be one session after the initial send event'); |
| expect(firstQuery.flutterChannelCount, 1, |
| reason: 'There should only be one flutter channel logged'); |
| expect(firstQuery.toolCount, 1, |
| reason: 'There should only be one tool logged'); |
| }); |
| |
| // Define a new clock that is outside of the session duration |
| final DateTime secondClock = |
| start.add(Duration(minutes: kSessionDurationMinutes + 1)); |
| |
| // Use the new clock to send an event that will change the session identifier |
| withClock(Clock.fixed(secondClock), () { |
| analytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| final LogFileStats secondQuery = analytics.logFileStats()!; |
| |
| // Construct the expected response for the second query |
| // |
| // This will need to be updated as the output for [LogFileStats] |
| // changes in the future |
| // |
| // Expecting the below returned |
| // { |
| // "startDateTime": "1995-03-03 12:00:00.000", |
| // "minsFromStartDateTime": 31, |
| // "endDateTime": "1995-03-03 12:31:00.000", |
| // "minsFromEndDateTime": 0, |
| // "sessionCount": 2, |
| // "flutterChannelCount": 1, |
| // "toolCount": 1, |
| // "recordCount": 2, |
| // "eventCount": { |
| // "hot_reload_time": 2 |
| // } |
| // } |
| expect(secondQuery.startDateTime, DateTime(1995, 3, 3, 12, 0)); |
| expect(secondQuery.minsFromStartDateTime, 31); |
| expect(secondQuery.endDateTime, DateTime(1995, 3, 3, 12, 31)); |
| expect(secondQuery.minsFromEndDateTime, 0); |
| expect(secondQuery.sessionCount, 2); |
| expect(secondQuery.flutterChannelCount, 1); |
| expect(secondQuery.toolCount, 1); |
| expect(secondQuery.recordCount, 2); |
| expect(secondQuery.eventCount, <String, int>{'hot_reload_time': 2}); |
| }); |
| }); |
| |
| test('Check that the log file shows two different tools being used', () { |
| // Use a for loop two initialize the second analytics instance |
| // twice to account for no events being sent on the first instance |
| // run for a given tool |
| Analytics? secondAnalytics; |
| for (int i = 0; i < 2; i++) { |
| secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| flutterChannel: flutterChannel, |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| } |
| |
| // Send events with both instances of the classes |
| analytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| secondAnalytics!.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| // Query the log file stats to verify that there are two tools |
| LogFileStats query = analytics.logFileStats()!; |
| |
| expect(query.toolCount, 2, |
| reason: 'There should have been two tools in the persisted logs'); |
| }); |
| |
| test('Check that log data missing some keys results in null for stats', () { |
| // The following string represents a log item that is malformed (missing the `tool` key) |
| const String malformedLog = |
| '{"client_id":"d40133a0-7ea6-4347-b668-ffae94bb8774",' |
| '"events":[{"name":"hot_reload_time","params":{"time_ns":345}}],' |
| '"user_properties":{' |
| '"session_id":{"value":1675193534342},' |
| '"flutter_channel":{"value":"ey-test-channel"},' |
| '"host":{"value":"macOS"},' |
| '"flutter_version":{"value":"Flutter 3.6.0-7.0.pre.47"},' |
| '"dart_version":{"value":"Dart 2.19.0"},' |
| // '"tool":{"value":"flutter-tools"},' NEEDS REMAIN REMOVED |
| '"local_time":{"value":"2023-01-31 14:32:14.592898 -0500"}}}'; |
| |
| logFile.writeAsStringSync(malformedLog); |
| final LogFileStats? query = analytics.logFileStats(); |
| |
| expect(query, isNull, |
| reason: |
| 'The query should be null because `tool` is missing under `user_properties`'); |
| }); |
| |
| test('Malformed local_time string should result in null for stats', () { |
| // The following string represents a log item that is malformed (missing the `tool` key) |
| const String malformedLog = |
| '{"client_id":"d40133a0-7ea6-4347-b668-ffae94bb8774",' |
| '"events":[{"name":"hot_reload_time","params":{"time_ns":345}}],' |
| '"user_properties":{' |
| '"session_id":{"value":1675193534342},' |
| '"flutter_channel":{"value":"ey-test-channel"},' |
| '"host":{"value":"macOS"},' |
| '"flutter_version":{"value":"Flutter 3.6.0-7.0.pre.47"},' |
| '"dart_version":{"value":"Dart 2.19.0"},' |
| '"tool":{"value":"flutter-tools"},' |
| '"local_time":{"value":"2023-xx-31 14:32:14.592898 -0500"}}}'; // PURPOSEFULLY MALFORMED |
| |
| logFile.writeAsStringSync(malformedLog); |
| final LogFileStats? query = analytics.logFileStats(); |
| |
| expect(query, isNull, |
| reason: |
| 'The query should be null because the `local_time` value is malformed'); |
| }); |
| |
| test('Version is the same in the change log, pubspec, and constants.dart', |
| () { |
| // Parse the contents of the pubspec.yaml |
| final String pubspecYamlString = io.File('pubspec.yaml').readAsStringSync(); |
| |
| // Parse into a yaml document to extract the version number |
| final YamlMap doc = loadYaml(pubspecYamlString); |
| final String version = doc['version']; |
| |
| expect(version, kPackageVersion, |
| reason: 'The package version in the pubspec and ' |
| 'constants.dart need to match\n' |
| 'Pubspec: $version && constants.dart: $kPackageVersion\n\n' |
| 'Make sure both are the same'); |
| |
| // Parse the contents of the change log file |
| final String changeLogFirstLineString = |
| io.File('CHANGELOG.md').readAsLinesSync().first; |
| expect(changeLogFirstLineString.substring(3), kPackageVersion, |
| reason: 'The CHANGELOG.md file needs the first line to ' |
| 'be the same version as the pubspec and constants.dart'); |
| }); |
| |
| test('Null values for flutter parameters is reflected properly in log file', |
| () { |
| // Use a for loop two initialize the second analytics instance |
| // twice to account for no events being sent on the first instance |
| // run for a given tool |
| Analytics? secondAnalytics; |
| for (int i = 0; i < 2; i++) { |
| secondAnalytics = Analytics.test( |
| tool: secondTool, |
| homeDirectory: home, |
| measurementId: 'measurementId', |
| apiSecret: 'apiSecret', |
| // flutterChannel: flutterChannel, THIS NEEDS TO REMAIN REMOVED |
| toolsMessageVersion: toolsMessageVersion, |
| toolsMessage: toolsMessage, |
| flutterVersion: 'Flutter 3.6.0-7.0.pre.47', |
| dartVersion: 'Dart 2.19.0', |
| fs: fs, |
| platform: platform, |
| ); |
| } |
| |
| // Send an event and check that the query stats reflects what is expected |
| secondAnalytics!.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| |
| // Query the log file stats to verify that there are two tools |
| LogFileStats query = analytics.logFileStats()!; |
| |
| expect(query.toolCount, 1, |
| reason: 'There should have only been on tool that sent events'); |
| expect(query.flutterChannelCount, 0, |
| reason: |
| 'The instance does not have flutter information so it should be 0'); |
| |
| // Sending a query with the first analytics instance which has flutter information |
| // available should reflect in the query that there is 1 flutter channel present |
| analytics.sendEvent( |
| eventName: DashEvent.hotReloadTime, eventData: <String, dynamic>{}); |
| LogFileStats? query2 = analytics.logFileStats()!; |
| |
| expect(query2.toolCount, 2, |
| reason: 'Two different analytics instances have ' |
| 'been initialized and sent events'); |
| expect(query2.sessionCount, query.sessionCount, |
| reason: 'The session should have remained the same'); |
| expect(query2.flutterChannelCount, 1, |
| reason: 'The first instance has flutter information initialized'); |
| }); |
| |
| group('Testing against Google Analytics limitations:', () { |
| // Link to limitations documentation |
| // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#limitations |
| // |
| // Only the limitations specified below have been added, the other |
| // are not able to be validated because it will vary by each tool |
| // |
| // 1. Events can have a maximum of 25 user properties |
| // 2. User property names must be 24 characters or fewer |
| // 3. (Only for `tool` name) User property values must be 36 characters or fewer |
| // 4. Event names must be 40 characters or fewer, may only contain alpha-numeric |
| // characters and underscores, and must start with an alphabetic character |
| test('max 25 user properties per event', () { |
| final Map<String, Object> userPropPayload = userProperty.preparePayload(); |
| const int maxUserPropKeys = 25; |
| |
| expect(userPropPayload.keys.length < maxUserPropKeys, true, |
| reason: 'There are too many keys in the UserProperty payload'); |
| }); |
| |
| test('max 24 characters for user prop keys', () { |
| final Map<String, Object> userPropPayload = userProperty.preparePayload(); |
| const int maxUserPropLength = 24; |
| |
| bool userPropLengthValid = true; |
| final List<String> invalidUserProps = <String>[]; |
| for (String key in userPropPayload.keys) { |
| if (key.length > maxUserPropLength) { |
| userPropLengthValid = false; |
| invalidUserProps.add(key); |
| } |
| } |
| expect(userPropLengthValid, true, |
| reason: |
| 'The max length for each user prop is $maxUserPropLength chars\n' |
| 'The below keys are too long:\n$invalidUserProps'); |
| }); |
| |
| test('max 36 characters for user prop values (only `tool` key)', () { |
| // Checks item 3 |
| // All tools must be under 36 characters (and enforce each tool |
| // begins with a letter) |
| final RegExp toolLabelPattern = RegExp(r'^[a-zA-Z][a-zA-Z\_]{0,35}$'); |
| bool toolLengthValid = true; |
| final List<DashTool> invalidTools = <DashTool>[]; |
| for (DashTool tool in DashTool.values) { |
| if (!toolLabelPattern.hasMatch(tool.label)) { |
| toolLengthValid = false; |
| invalidTools.add(tool); |
| } |
| } |
| |
| expect(toolLengthValid, true, |
| reason: |
| 'All tool labels must be under 36 characters and begin with a letter\n' |
| 'The following are invalid\n$invalidTools'); |
| }); |
| |
| test('max 40 characters for event names', () { |
| // Check that each event name is less than 40 chars and starts with |
| // an alphabetic character; the entire string has to be alphanumeric |
| // and underscores |
| final RegExp eventLabelPattern = |
| RegExp(r'^[a-zA-Z]{1}[a-zA-Z0-9\_]{0,39}$'); |
| bool eventValid = true; |
| final List<DashEvent> invalidEvents = <DashEvent>[]; |
| for (DashEvent event in DashEvent.values) { |
| if (!eventLabelPattern.hasMatch(event.label)) { |
| eventValid = false; |
| invalidEvents.add(event); |
| } |
| } |
| |
| expect(eventValid, true, |
| reason: 'All event labels should have letters and underscores ' |
| 'as a delimiter if needed; invalid events below\n$invalidEvents'); |
| }); |
| }); |
| |
| test('Confirm credentials for GA', () { |
| expect(kGoogleAnalyticsApiSecret, 'Ka1jc8tZSzWc_GXMWHfPHA'); |
| expect(kGoogleAnalyticsMeasurementId, 'G-04BXPVBCWJ'); |
| }); |
| } |