blob: ed2bd9ab2a51fb36f45b2187b0e5b2fa9111a83f [file] [log] [blame]
// 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');
});
}