blob: 39b2df8f55b52d35cce397f5c30aee6c4ff5e481 [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: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),
);
});
});
}